From 88f572cfce310680affceeef4d8bfd075f2e4c66 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 22 Jul 2026 16:19:00 -0400 Subject: [PATCH 01/30] refactor(tui): migrate selection views to V2 theme (#38001) --- .../tui/src/component/dialog-session-list.tsx | 13 ++-- packages/tui/src/component/dialog-stash.tsx | 5 +- .../tui/src/component/prompt/autocomplete.tsx | 24 ++++-- packages/tui/src/ui/dialog-select.tsx | 78 ++++++++++++------- 4 files changed, 77 insertions(+), 43 deletions(-) diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 04783790e46..b1067fa8598 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -20,7 +20,7 @@ export function DialogSessionList() { const dialog = useDialog() const route = useRoute() const data = useData() - const { theme } = useTheme() + const { themeV2, mode } = useTheme().contextual("elevated") const client = useClient() const local = useLocal() const toast = useToast() @@ -109,12 +109,13 @@ export function DialogSessionList() { value: session.id, category, footer, - bg: deleting ? theme.error : undefined, + bg: deleting ? themeV2.background.action.destructive.focused : undefined, + fg: deleting ? themeV2.text.action.destructive.focused : undefined, gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running") ? () => : slot === undefined ? undefined - : () => {slot}, + : () => {slot}, } } @@ -142,12 +143,14 @@ export function DialogSessionList() { }} emptyView={ - No sessions available + No sessions available } noMatchView={ - {searchState().message} + + {searchState().message} + } onMove={() => setToDelete(undefined)} diff --git a/packages/tui/src/component/dialog-stash.tsx b/packages/tui/src/component/dialog-stash.tsx index cefe315ee3a..80aa75250cb 100644 --- a/packages/tui/src/component/dialog-stash.tsx +++ b/packages/tui/src/component/dialog-stash.tsx @@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string { export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() - const { theme } = useTheme() + const { themeV2 } = useTheme().contextual("elevated") const shortcuts = Keymap.useShortcuts() const [toDelete, setToDelete] = createSignal() @@ -45,7 +45,8 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { title: isDeleting ? `Press ${shortcuts.get("stash.delete")} again to confirm` : getStashPreview(entry.prompt.text), - bg: isDeleting ? theme.error : undefined, + bg: isDeleting ? themeV2.background.action.destructive.focused : undefined, + fg: isDeleting ? themeV2.text.action.destructive.focused : undefined, value: index, description: getRelativeTime(entry.timestamp), footer: lineCount > 1 ? `~${lineCount} lines` : undefined, diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 5dd80ee3967..921d096819c 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll" import { useTuiPaths } from "../../context/runtime" import { useConfig } from "../../config" import { useLocation } from "../../context/location" -import { useTheme, selectedForeground } from "../../context/theme" +import { useTheme } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" @@ -57,7 +57,7 @@ export function Autocomplete(props: { const data = useData() const keymap = Keymap.use() const keymapCommands = Keymap.useCommands() - const { theme } = useTheme() + const { themeV2 } = useTheme().contextual("overlay") const dimensions = useTerminalDimensions() const frecency = useFrecency() const config = useConfig().data @@ -698,11 +698,11 @@ export function Autocomplete(props: { width={position().width} zIndex={100} {...SplitBorder} - borderColor={theme.border} + borderColor={themeV2.border.default} > (scroll = r)} - backgroundColor={theme.backgroundMenu} + backgroundColor={themeV2.background.default} height={height()} scrollbarOptions={{ visible: false }} scrollAcceleration={scrollAcceleration()} @@ -711,7 +711,9 @@ export function Autocomplete(props: { each={options()} fallback={ - {emptyMessage()} + + {emptyMessage()} + } > @@ -719,7 +721,7 @@ export function Autocomplete(props: { { setStore("input", "mouse") @@ -734,11 +736,17 @@ export function Autocomplete(props: { }} onMouseUp={() => select()} > - + {option().display} - + {" " + option().description?.trimStart()} diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 257de75b179..88248c92d34 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -1,6 +1,6 @@ import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { Keymap, type KeymapCommand } from "../context/keymap" -import { useTheme, selectedForeground } from "../context/theme" +import { useTheme } from "../context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" @@ -74,6 +74,7 @@ export interface DialogSelectOption { categoryView?: JSX.Element disabled?: boolean bg?: RGBA + fg?: RGBA gutter?: () => JSX.Element margin?: JSX.Element onSelect?: (ctx: DialogContext) => void @@ -91,7 +92,7 @@ export function DialogSelect(props: DialogSelectProps) { type VisibleAction = (Action & { label: string }) | FooterHint const dialog = useDialog() - const { theme } = useTheme() + const { themeV2, mode } = useTheme().contextual("elevated") const config = useConfig().data const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) @@ -517,29 +518,44 @@ export function DialogSelect(props: DialogSelectProps) { if (!isActionItem(action.item)) return ( - + {action.item.title}{" "} - {action.item.label} + {action.item.label} ) const item = action.item const active = createMemo(() => isActionFocused(item)) const disabled = createMemo(() => isActionDisabled(item)) - const fg = selectedForeground(theme) return ( trigger(item)} > {item.title} - {item.label} + + {" " + item.label} + ) } @@ -549,11 +565,11 @@ export function DialogSelect(props: DialogSelectProps) { {props.titleView ?? ( - + {props.title} )} - dialog.clear()}> + dialog.clear()}> esc @@ -567,9 +583,9 @@ export function DialogSelect(props: DialogSelectProps) { props.onFilter?.(e) }) }} - focusedBackgroundColor={theme.backgroundPanel} - cursorColor={theme.primary} - focusedTextColor={theme.textMuted} + focusedBackgroundColor={themeV2.background.formfield.focused} + cursorColor={themeV2.text.formfield.focused} + focusedTextColor={themeV2.text.formfield.focused} ref={(r) => { input = r input.traits = { status: "FILTER" } @@ -580,7 +596,7 @@ export function DialogSelect(props: DialogSelectProps) { }, 1) }} placeholder={props.placeholder ?? "Search"} - placeholderColor={theme.textMuted} + placeholderColor={themeV2.text.subdued} /> @@ -594,14 +610,14 @@ export function DialogSelect(props: DialogSelectProps) { fallback={ props.emptyView ?? ( - No items available + No items available ) } > {props.noMatchView ?? ( - No results found + No results found )} @@ -623,7 +639,10 @@ export function DialogSelect(props: DialogSelectProps) { + {category} } @@ -672,8 +691,8 @@ export function DialogSelect(props: DialogSelectProps) { backgroundColor={ active() ? actionFocused() - ? theme.backgroundElement - : (option.bg ?? theme.primary) + ? themeV2.background.surface.overlay + : (option.bg ?? themeV2.background.action.primary.focused) : RGBA.fromInts(0, 0, 0, 0) } > @@ -692,6 +711,7 @@ export function DialogSelect(props: DialogSelectProps) { active={active()} current={current()} muted={actionFocused()} + activeColor={option.fg} gutter={option.gutter} /> @@ -699,7 +719,7 @@ export function DialogSelect(props: DialogSelectProps) { {(detail) => ( {option.detailsWrap @@ -745,15 +765,15 @@ function Option(props: { titleWidth?: number truncateTitle?: boolean | "left" gutter?: () => JSX.Element + activeColor?: RGBA onMouseOver?: () => void }) { - const { theme } = useTheme() - const fg = selectedForeground(theme) + const { themeV2 } = useTheme().contextual("elevated") const text = createMemo(() => { - if (props.active && !props.muted) return fg - if (props.muted && (props.active || props.current)) return theme.textMuted - if (props.current) return theme.primary - return theme.text + if (props.active && !props.muted) return props.activeColor ?? themeV2.text.action.primary.focused + if (props.muted && (props.active || props.current)) return themeV2.text.subdued + if (props.current) return themeV2.text.formfield.selected + return themeV2.text.default }) return ( @@ -783,12 +803,14 @@ function Option(props: { ? Locale.truncateLeft(props.title, props.titleWidth ?? 61) : Locale.truncate(props.title, props.titleWidth ?? 61))} - {props.description} + + {" " + props.description} + - {props.footer} + {props.footer} From 5913c1db0bf7a5e7475b946f7681d7c3a77bcf2f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:33:14 -0500 Subject: [PATCH 02/30] chore: merge dev into v2 (#38377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: opencode-agent[bot] Co-authored-by: Frank Co-authored-by: Aiden Cline Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Dax Raad Co-authored-by: Dax Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Nabs Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan Co-authored-by: Victor Navarro Co-authored-by: Vladimir Glafirov Co-authored-by: AidenGeunGeun Co-authored-by: Mark Co-authored-by: Aiden Cline Co-authored-by: opencode Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Jay Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com> Co-authored-by: Dustin Deus Co-authored-by: Jack Co-authored-by: Sebastian Co-authored-by: Jérôme Benoit Co-authored-by: Test User Co-authored-by: Simon Klee Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Co-authored-by: Qiping Li Co-authored-by: liqiping Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com> Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com> Co-authored-by: Daniel Polito Co-authored-by: opencode --- .../session-timeline-lifecycle-state.spec.ts | 17 +++++++++++++++++ .../src/pages/layout/project-avatar-state.ts | 12 +++++++----- packages/console/app/src/i18n/ar.ts | 8 ++++---- packages/console/app/src/i18n/br.ts | 8 ++++---- packages/console/app/src/i18n/da.ts | 8 ++++---- packages/console/app/src/i18n/de.ts | 8 ++++---- packages/console/app/src/i18n/en.ts | 8 ++++---- packages/console/app/src/i18n/es.ts | 8 ++++---- packages/console/app/src/i18n/fr.ts | 8 ++++---- packages/console/app/src/i18n/it.ts | 8 ++++---- packages/console/app/src/i18n/ja.ts | 8 ++++---- packages/console/app/src/i18n/ko.ts | 8 ++++---- packages/console/app/src/i18n/no.ts | 8 ++++---- packages/console/app/src/i18n/pl.ts | 8 ++++---- packages/console/app/src/i18n/ru.ts | 8 ++++---- packages/console/app/src/i18n/th.ts | 8 ++++---- packages/console/app/src/i18n/tr.ts | 8 ++++---- packages/console/app/src/i18n/uk.ts | 8 ++++---- packages/console/app/src/i18n/zh.ts | 8 ++++---- packages/console/app/src/i18n/zht.ts | 8 ++++---- packages/console/app/src/routes/go/index.tsx | 2 ++ .../routes/workspace/[id]/go/lite-section.tsx | 1 + .../session-ui/src/components/basic-tool.tsx | 5 +++-- .../session-ui/src/components/message-part.tsx | 3 ++- packages/web/src/content/docs/ar/go.mdx | 7 ++++++- packages/web/src/content/docs/bs/go.mdx | 7 ++++++- packages/web/src/content/docs/da/go.mdx | 7 ++++++- packages/web/src/content/docs/de/go.mdx | 7 ++++++- packages/web/src/content/docs/es/go.mdx | 7 ++++++- packages/web/src/content/docs/fr/go.mdx | 7 ++++++- packages/web/src/content/docs/go.mdx | 7 ++++++- packages/web/src/content/docs/it/go.mdx | 7 ++++++- packages/web/src/content/docs/ja/go.mdx | 7 ++++++- packages/web/src/content/docs/ko/go.mdx | 7 ++++++- packages/web/src/content/docs/nb/go.mdx | 7 ++++++- packages/web/src/content/docs/pl/go.mdx | 7 ++++++- packages/web/src/content/docs/pt-br/go.mdx | 7 ++++++- packages/web/src/content/docs/ru/go.mdx | 7 ++++++- packages/web/src/content/docs/th/go.mdx | 7 ++++++- packages/web/src/content/docs/tr/go.mdx | 7 ++++++- packages/web/src/content/docs/zh-cn/go.mdx | 7 ++++++- packages/web/src/content/docs/zh-tw/go.mdx | 7 ++++++- 42 files changed, 212 insertions(+), 98 deletions(-) diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index 3e2b171bca0..b303071c87f 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -32,6 +32,23 @@ for (const expanded of [false, true]) { }) } +test("shows and expands a running shell command without shimmering it", async ({ page }) => { + const id = "prt_shell_running_command" + const command = "sleep 10 && echo done" + await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })], + settings: { shellToolPartsExpanded: false }, + }) + + const tool = page.locator(`[data-timeline-part-id="${id}"]`) + await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") + await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command) + await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0) + await tool.locator('[data-slot="collapsible-trigger"]').click() + await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") + await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running") +}) + test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { const reasoningID = "prt_reasoning_hidden" const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) diff --git a/packages/app/src/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts index 8e5dc38d671..236f6bd4053 100644 --- a/packages/app/src/pages/layout/project-avatar-state.ts +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -13,7 +13,6 @@ export function useSessionTabAvatarState( const global = useGlobal() const notification = useNotification() const permission = usePermission() - const permissionState = createMemo(() => permission.ensureServerState(server())) const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) const sync = createMemo(() => { const conn = connection() @@ -22,9 +21,10 @@ export function useSessionTabAvatarState( const hasPermissions = createMemo(() => { const serverSync = sync() if (!serverSync) return false + const permissionState = permission.ensureServerState(server()) const [store] = serverSync.child(directory(), { bootstrap: false }) return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => { - return !permissionState().autoResponds(item, directory()) + return !permissionState.autoResponds(item, directory()) }) }) const hasQuestions = createMemo(() => { @@ -34,9 +34,11 @@ export function useSessionTabAvatarState( return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId()) }) const needsAttention = createMemo(() => hasPermissions() || hasQuestions()) - const unread = createMemo( - () => needsAttention() || notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0, - ) + const notificationState = createMemo(() => { + if (!connection()) return + return notification.ensureServerState(server()) + }) + const unread = createMemo(() => needsAttention() || (notificationState()?.session.unseenCount(sessionId()) ?? 0) > 0) const loading = createMemo(() => { const serverSync = sync() if (!serverSync) return false diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 991f7fb2d33..082e211e0be 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -254,7 +254,7 @@ export const dict = { "go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع", "go.banner.text": "يحصل Kimi K3 على حدود استخدام مضاعفة لفترة محدودة", "go.meta.description": - "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3.", "go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع", "go.hero.body": "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "حدود سخية ووصول موثوق", "go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين", "go.problem.item4": - "يتضمن Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash", + "يتضمن Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3", "go.how.title": "كيف يعمل Go", "go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.", "go.how.step1.title": "أنشئ حسابًا", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.", "go.faq.q3": "هل Go هو نفسه Zen؟", "go.faq.a3": - "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3.", "go.faq.q4": "كم تكلفة Go؟", "go.faq.a4.p1.beforePricing": "تكلفة Go", "go.faq.a4.p1.pricingLink": "$5 للشهر الأول", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟", "go.faq.a9": - "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", + "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.", "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 9bef420e85c..69979b0a441 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -258,7 +258,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos", "go.banner.text": "Kimi K3 tem limites de uso 2x maiores por tempo limitado", "go.meta.description": - "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3.", "go.hero.title": "Modelos de codificação de baixo custo para todos", "go.hero.body": "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", @@ -307,7 +307,7 @@ export const dict = { "go.problem.item2": "Limites generosos e acesso confiável", "go.problem.item3": "Feito para o maior número possível de programadores", "go.problem.item4": - "Inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3", "go.how.title": "Como o Go funciona", "go.how.body": "O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.", @@ -333,7 +333,7 @@ export const dict = { "go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.", "go.faq.q3": "O Go é o mesmo que o Zen?", "go.faq.a3": - "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3.", "go.faq.q4": "Quanto custa o Go?", "go.faq.a4.p1.beforePricing": "O Go custa", "go.faq.a4.p1.pricingLink": "$5 no primeiro mês", @@ -357,7 +357,7 @@ export const dict = { "go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?", "go.faq.a9": - "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", + "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", "zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} não suportado", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index e7c8c8feaf8..43d8d51abb6 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -256,7 +256,7 @@ export const dict = { "go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle", "go.banner.text": "Kimi K3 får fordoblet brugsgrænse i en begrænset periode", "go.meta.description": - "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3.", "go.hero.title": "Kodningsmodeller til lav pris for alle", "go.hero.body": "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Generøse grænser og pålidelig adgang", "go.problem.item3": "Bygget til så mange programmører som muligt", "go.problem.item4": - "Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3", "go.how.title": "Hvordan Go virker", "go.how.body": "Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3.", "go.faq.q4": "Hvad koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Hvad er forskellen på gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", + "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", "zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.", "zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 465b24568f6..99446d92b08 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -258,7 +258,7 @@ export const dict = { "go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle", "go.banner.text": "Kimi K3 erhält für begrenzte Zeit 2x Nutzungslimits", "go.meta.description": - "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3.", "go.hero.title": "Kostengünstige Coding-Modelle für alle", "go.hero.body": "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", @@ -306,7 +306,7 @@ export const dict = { "go.problem.item2": "Großzügige Limits und zuverlässiger Zugang", "go.problem.item3": "Für so viele Programmierer wie möglich gebaut", "go.problem.item4": - "Beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash", + "Beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3", "go.how.title": "Wie Go funktioniert", "go.how.body": "Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.", @@ -332,7 +332,7 @@ export const dict = { "go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.", "go.faq.q3": "Ist Go dasselbe wie Zen?", "go.faq.a3": - "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3.", "go.faq.q4": "Wie viel kostet Go?", "go.faq.a4.p1.beforePricing": "Go kostet", "go.faq.a4.p1.pricingLink": "$5 im ersten Monat", @@ -356,7 +356,7 @@ export const dict = { "go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?", "go.faq.a9": - "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", + "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", "zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.", "zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 7d0531e6f05..690658c657a 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Low cost coding models for everyone", "go.banner.text": "Kimi K3 gets 2× usage limits for a limited time", "go.meta.description": - "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3.", "go.hero.title": "Low cost coding models for everyone", "go.hero.body": "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "Generous limits and reliable access", "go.problem.item3": "Built for as many programmers as possible", "go.problem.item4": - "Includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash", + "Includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3", "go.how.title": "How Go works", "go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.", "go.how.step1.title": "Create an account", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.", "go.faq.q3": "Is Go the same as Zen?", "go.faq.a3": - "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3.", "go.faq.q4": "How much does Go cost?", "go.faq.a4.p1.beforePricing": "Go costs", "go.faq.a4.p1.pricingLink": "$5 first month", @@ -351,7 +351,7 @@ export const dict = { "go.faq.q9": "What is the difference between free models and Go?", "go.faq.a9": - "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", + "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", "zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.", "zen.api.error.modelNotSupported": "Model {{model}} is not supported", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 08d30aef68e..bb1a44138f7 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -259,7 +259,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de programación de bajo coste para todos", "go.banner.text": "Kimi K3 tiene límites de uso 2x mayores por tiempo limitado", "go.meta.description": - "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3.", "go.hero.title": "Modelos de programación de bajo coste para todos", "go.hero.body": "Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.", @@ -308,7 +308,7 @@ export const dict = { "go.problem.item2": "Límites generosos y acceso fiable", "go.problem.item3": "Creado para tantos programadores como sea posible", "go.problem.item4": - "Incluye Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash", + "Incluye Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3", "go.how.title": "Cómo funciona Go", "go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.", "go.how.step1.title": "Crear una cuenta", @@ -333,7 +333,7 @@ export const dict = { "go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.", "go.faq.q3": "¿Es Go lo mismo que Zen?", "go.faq.a3": - "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3.", "go.faq.q4": "¿Cuánto cuesta Go?", "go.faq.a4.p1.beforePricing": "Go cuesta", "go.faq.a4.p1.pricingLink": "$5 el primer mes", @@ -357,7 +357,7 @@ export const dict = { "go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?", "go.faq.a9": - "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", + "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", "zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} no soportado", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index a0b84441958..4d0ff288d69 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -260,7 +260,7 @@ export const dict = { "go.title": "OpenCode Go | Modèles de code à faible coût pour tous", "go.banner.text": "Kimi K3 bénéficie de limites d’utilisation 2x supérieures pour une durée limitée", "go.meta.description": - "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3.", "go.hero.title": "Modèles de code à faible coût pour tous", "go.hero.body": "Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.", @@ -308,7 +308,7 @@ export const dict = { "go.problem.item2": "Limites généreuses et accès fiable", "go.problem.item3": "Conçu pour autant de programmeurs que possible", "go.problem.item4": - "Inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash", + "Inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3", "go.how.title": "Comment fonctionne Go", "go.how.body": "Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.", @@ -334,7 +334,7 @@ export const dict = { "go.faq.a2": "Go inclut les modèles ci-dessous, avec des limites généreuses et un accès fiable.", "go.faq.q3": "Est-ce que Go est la même chose que Zen ?", "go.faq.a3": - "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3.", "go.faq.q4": "Combien coûte Go ?", "go.faq.a4.p1.beforePricing": "Go coûte", "go.faq.a4.p1.pricingLink": "$5 le premier mois", @@ -357,7 +357,7 @@ export const dict = { "Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.", "go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?", "go.faq.a9": - "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", + "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", "zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.", "zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index a5e37dfc60b..effeb1fdb42 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -256,7 +256,7 @@ export const dict = { "go.title": "OpenCode Go | Modelli di coding a basso costo per tutti", "go.banner.text": "Kimi K3 offre limiti di utilizzo 2x superiori per un periodo limitato", "go.meta.description": - "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3.", "go.hero.title": "Modelli di coding a basso costo per tutti", "go.hero.body": "Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Limiti generosi e accesso affidabile", "go.problem.item3": "Costruito per il maggior numero possibile di programmatori", "go.problem.item4": - "Include Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Include Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3", "go.how.title": "Come funziona Go", "go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.", "go.how.step1.title": "Crea un account", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go include i modelli elencati di seguito, con limiti generosi e accesso affidabile.", "go.faq.q3": "Go è lo stesso di Zen?", "go.faq.a3": - "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3.", "go.faq.q4": "Quanto costa Go?", "go.faq.a4.p1.beforePricing": "Go costa", "go.faq.a4.p1.pricingLink": "$5 il primo mese", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?", "go.faq.a9": - "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", + "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", "zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.", "zen.api.error.modelNotSupported": "Modello {{model}} non supportato", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index aca480b7197..6dfd750c6ad 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル", "go.banner.text": "Kimi K3の利用上限が期間限定で2倍に", "go.meta.description": - "Goは最初の月$5、その後$10/月で、Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", + "Goは最初の月$5、その後$10/月で、Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3に対して5時間のゆとりあるリクエスト上限があります。", "go.hero.title": "すべての人のための低価格なコーディングモデル", "go.hero.body": "Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "十分な制限と安定したアクセス", "go.problem.item3": "できるだけ多くのプログラマーのために構築", "go.problem.item4": - "Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", + "Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3を含む", "go.how.title": "Goの仕組み", "go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。", "go.how.step1.title": "アカウントを作成", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go には、十分な利用上限と安定したアクセスを備えた、以下のモデルが含まれます。", "go.faq.q3": "GoはZenと同じですか?", "go.faq.a3": - "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", + "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", "go.faq.q4": "Goの料金は?", "go.faq.a4.p1.beforePricing": "Goは", "go.faq.a4.p1.pricingLink": "最初の月$5", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "無料モデルとGoの違いは何ですか?", "go.faq.a9": - "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGrok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", + "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGrok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3が含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", "zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。", "zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index f1e2235d7eb..a24e988d71e 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델", "go.banner.text": "Kimi K3 사용 한도가 한시적으로 2배 확대됩니다", "go.meta.description": - "Go는 첫 달 $5, 이후 $10/월로 시작하며, Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", + "Go는 첫 달 $5, 이후 $10/월로 시작하며, Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3에 대해 넉넉한 5시간 요청 한도를 제공합니다.", "go.hero.title": "모두를 위한 저비용 코딩 모델", "go.hero.body": "Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "넉넉한 한도와 안정적인 액세스", "go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨", "go.problem.item4": - "Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", + "Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 포함", "go.how.title": "Go 작동 방식", "go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.", "go.how.step1.title": "계정 생성", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 아래 모델이 포함됩니다.", "go.faq.q3": "Go는 Zen과 같은가요?", "go.faq.a3": - "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", + "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", "go.faq.q4": "Go 비용은 얼마인가요?", "go.faq.a4.p1.beforePricing": "Go 비용은", "go.faq.a4.p1.pricingLink": "첫 달 $5", @@ -348,7 +348,7 @@ export const dict = { "go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?", "go.faq.a9": - "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", + "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", "zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.", "zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index bec0e0ce5ef..b5ceff412c6 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -256,7 +256,7 @@ export const dict = { "go.title": "OpenCode Go | Rimelige kodemodeller for alle", "go.banner.text": "Kimi K3 får 2x bruksgrense i en begrenset periode", "go.meta.description": - "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3.", "go.hero.title": "Rimelige kodemodeller for alle", "go.hero.body": "Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Rause grenser og pålitelig tilgang", "go.problem.item3": "Bygget for så mange programmerere som mulig", "go.problem.item4": - "Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3", "go.how.title": "Hvordan Go fungerer", "go.how.body": "Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellene nedenfor, med høye grenser og pålitelig tilgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3.", "go.faq.q4": "Hva koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -354,7 +354,7 @@ export const dict = { "go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", + "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", "zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.", "zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 8be53855f37..3199606a8b3 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -257,7 +257,7 @@ export const dict = { "go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego", "go.banner.text": "Kimi K3 oferuje 2x wyższe limity użycia przez ograniczony czas", "go.meta.description": - "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3.", "go.hero.title": "Niskokosztowe modele do kodowania dla każdego", "go.hero.body": "Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.", @@ -305,7 +305,7 @@ export const dict = { "go.problem.item2": "Hojne limity i niezawodny dostęp", "go.problem.item3": "Stworzony dla jak największej liczby programistów", "go.problem.item4": - "Zawiera Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash", + "Zawiera Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3", "go.how.title": "Jak działa Go", "go.how.body": "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.", @@ -331,7 +331,7 @@ export const dict = { "go.faq.a2": "Go obejmuje poniższe modele z wysokimi limitami i niezawodnym dostępem.", "go.faq.q3": "Czy Go to to samo co Zen?", "go.faq.a3": - "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3.", "go.faq.q4": "Ile kosztuje Go?", "go.faq.a4.p1.beforePricing": "Go kosztuje", "go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc", @@ -355,7 +355,7 @@ export const dict = { "go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?", "go.faq.a9": - "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", + "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", "zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.", "zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index abe56bbb033..821ed70e98b 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -260,7 +260,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогие модели для кодинга для всех", "go.banner.text": "Kimi K3 получает 2x лимиты использования на ограниченное время", "go.meta.description": - "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3.", "go.hero.title": "Недорогие модели для кодинга для всех", "go.hero.body": "Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.", @@ -309,7 +309,7 @@ export const dict = { "go.problem.item2": "Щедрые лимиты и надежный доступ", "go.problem.item3": "Создан для максимального числа программистов", "go.problem.item4": - "Включает Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash", + "Включает Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3", "go.how.title": "Как работает Go", "go.how.body": "Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.", @@ -335,7 +335,7 @@ export const dict = { "go.faq.a2": "Go включает перечисленные ниже модели с щедрыми лимитами и надежным доступом.", "go.faq.q3": "Go — это то же самое, что и Zen?", "go.faq.a3": - "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3.", "go.faq.q4": "Сколько стоит Go?", "go.faq.a4.p1.beforePricing": "Go стоит", "go.faq.a4.p1.pricingLink": "$5 за первый месяц", @@ -359,7 +359,7 @@ export const dict = { "go.faq.q9": "В чем разница между бесплатными моделями и Go?", "go.faq.a9": - "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", + "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", "zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.", "zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index a6069a1bed4..2a68e94c0a3 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.banner.text": "Kimi K3 เพิ่มโควตาการใช้งานเป็น 2 เท่าในช่วงเวลาจำกัด", "go.meta.description": - "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3", "go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.hero.body": "Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้", "go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้", "go.problem.item4": - "รวมถึง Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "รวมถึง Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3", "go.how.title": "Go ทำงานอย่างไร", "go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้", "go.how.step1.title": "สร้างบัญชี", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go รวมโมเดลด้านล่างนี้ พร้อมขีดจำกัดที่มากและการเข้าถึงที่เชื่อถือได้", "go.faq.q3": "Go เหมือนกับ Zen หรือไม่?", "go.faq.a3": - "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", + "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 อย่างเชื่อถือได้", "go.faq.q4": "Go ราคาเท่าไหร่?", "go.faq.a4.p1.beforePricing": "Go ราคา", "go.faq.a4.p1.pricingLink": "$5 เดือนแรก", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?", "go.faq.a9": - "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", + "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", "zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง", "zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 7d8bc49f506..9bdcfeaeb44 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -258,7 +258,7 @@ export const dict = { "go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri", "go.banner.text": "Kimi K3 sınırlı bir süre için 2x kullanım limiti sunuyor", "go.meta.description": - "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", + "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 için cömert 5 saatlik istek limitleri sunar.", "go.hero.title": "Herkes için düşük maliyetli kodlama modelleri", "go.hero.body": "Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.", @@ -307,7 +307,7 @@ export const dict = { "go.problem.item2": "Cömert limitler ve güvenilir erişim", "go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi", "go.problem.item4": - "Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", + "Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 içerir", "go.how.title": "Go nasıl çalışır?", "go.how.body": "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.", @@ -333,7 +333,7 @@ export const dict = { "go.faq.a2": "Go, aşağıda listelenen modelleri cömert limitler ve güvenilir erişimle sunar.", "go.faq.q3": "Go, Zen ile aynı mı?", "go.faq.a3": - "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", + "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", "go.faq.q4": "Go ne kadar?", "go.faq.a4.p1.beforePricing": "Go'nun maliyeti", "go.faq.a4.p1.pricingLink": "İlk ay $5", @@ -357,7 +357,7 @@ export const dict = { "go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?", "go.faq.a9": - "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", + "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", "zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.", "zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor", diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index ee2405b65f2..1dbb0be8afb 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -257,7 +257,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогі моделі кодування для всіх", "go.banner.text": "Kimi K3 отримує 2x ліміти використання протягом обмеженого часу", "go.meta.description": - "Go починається від $5 за перший місяць, потім $10/місяць, зі щедрими 5-годинними лімітами запитів для Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", + "Go починається від $5 за перший місяць, потім $10/місяць, зі щедрими 5-годинними лімітами запитів для Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash та Hy3.", "go.hero.title": "Недорогі моделі кодування для всіх", "go.hero.body": "Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.", @@ -305,7 +305,7 @@ export const dict = { "go.problem.item2": "Щедрі ліміти та надійний доступ", "go.problem.item3": "Створено для якомога більшої кількості програмістів", "go.problem.item4": - "Включає Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash", + "Включає Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash та Hy3", "go.how.title": "Як працює Go", "go.how.body": "Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.", @@ -331,7 +331,7 @@ export const dict = { "go.faq.a2": "Go включає моделі, перелічені нижче, із щедрими лімітами та надійним доступом.", "go.faq.q3": "Чи Go те саме, що Zen?", "go.faq.a3": - "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", + "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash та Hy3.", "go.faq.q4": "Скільки коштує Go?", "go.faq.a4.p1.beforePricing": "Go коштує", "go.faq.a4.p1.pricingLink": "$5 за перший місяць", @@ -354,7 +354,7 @@ export const dict = { "go.faq.q9": "Яка різниця між безкоштовними моделями та Go?", "go.faq.a9": - "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.", + "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash та Hy3 із вищими лімітами.", "zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.", "zen.api.error.modelNotSupported": "Модель {{model}} не підтримується", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index cc8b6326f7e..47e5ee8361c 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -246,7 +246,7 @@ export const dict = { "go.title": "OpenCode Go | 人人可用的低成本编程模型", "go.banner.text": "Kimi K3 限时享受 2 倍使用额度", "go.meta.description": - "Go 首月 $5,之后 $10/月,提供对 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", + "Go 首月 $5,之后 $10/月,提供对 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3 的 5 小时充裕请求额度。", "go.hero.title": "人人可用的低成本编程模型", "go.hero.body": "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", @@ -293,7 +293,7 @@ export const dict = { "go.problem.item2": "充裕的限额和可靠的访问", "go.problem.item3": "为尽可能多的程序员打造", "go.problem.item4": - "包含 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash", + "包含 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3", "go.how.title": "Go 如何工作", "go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "创建账户", @@ -315,7 +315,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的限额和可靠的访问。", "go.faq.q3": "Go 和 Zen 一样吗?", "go.faq.a3": - "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", + "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3 等开源模型。", "go.faq.q4": "Go 多少钱?", "go.faq.a4.p1.beforePricing": "Go 费用为", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -337,7 +337,7 @@ export const dict = { "go.faq.q9": "免费模型和 Go 之间的区别是什么?", "go.faq.a9": - "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", + "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", "zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。", "zen.api.error.modelNotSupported": "不支持模型 {{model}}", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 8612bb8dacd..77c0e8e918e 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -246,7 +246,7 @@ export const dict = { "go.title": "OpenCode Go | 低成本全民編碼模型", "go.banner.text": "Kimi K3 限時享有 2 倍使用額度", "go.meta.description": - "Go 首月 $5,之後 $10/月,提供對 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", + "Go 首月 $5,之後 $10/月,提供對 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3 的 5 小時充裕請求額度。", "go.hero.title": "低成本全民編碼模型", "go.hero.body": "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", @@ -293,7 +293,7 @@ export const dict = { "go.problem.item2": "寬裕的限額與穩定存取", "go.problem.item3": "專為盡可能多的程式設計師打造", "go.problem.item4": - "包含 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash", + "包含 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 與 Hy3", "go.how.title": "Go 如何運作", "go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "建立帳號", @@ -315,7 +315,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的額度與穩定的存取。", "go.faq.q3": "Go 與 Zen 一樣嗎?", "go.faq.a3": - "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", + "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3 等開源模型。", "go.faq.q4": "Go 費用是多少?", "go.faq.a4.p1.beforePricing": "Go 費用為", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -337,7 +337,7 @@ export const dict = { "go.faq.q9": "免費模型與 Go 有什麼區別?", "go.faq.a9": - "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", + "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 與 Hy3,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", "zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。", "zen.api.error.modelNotSupported": "不支援模型 {{model}}", diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 9dedcdcbb4e..2742f49ef4a 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -38,6 +38,7 @@ const models = [ "MiniMax M2.7", "DeepSeek V4 Pro", "DeepSeek V4 Flash", + "Hy3", ] function LimitsGraph(props: { href: string }) { @@ -72,6 +73,7 @@ function LimitsGraph(props: { href: string }) { { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "240ms" }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "270ms" }, { id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "300ms" }, + { id: "hy3", name: "Hy3", req: 4300, d: "320ms" }, { id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" }, ] diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 6704f926089..88141656f31 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -321,6 +321,7 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
  • DeepSeek V4 Flash
  • MiMo-V2.5
  • MiMo-V2.5-Pro
  • +
  • Hy3
  • {i18n.t("workspace.lite.promo.footer")}

    diff --git a/packages/session-ui/src/components/basic-tool.tsx b/packages/session-ui/src/components/basic-tool.tsx index a3ce5c13b5d..2b73db24ed1 100644 --- a/packages/session-ui/src/components/basic-tool.tsx +++ b/packages/session-ui/src/components/basic-tool.tsx @@ -32,6 +32,7 @@ export interface BasicToolProps { open?: boolean onOpenChange?: (open: boolean) => void forceOpen?: boolean + allowOpenWhilePending?: boolean defer?: boolean locked?: boolean animated?: boolean @@ -176,7 +177,7 @@ export function BasicTool(props: BasicToolProps) { }) const handleOpenChange = (value: boolean) => { - if (pending()) return + if (pending() && !props.allowOpenWhilePending) return if (props.locked && !value) return setOpen(value) } @@ -247,7 +248,7 @@ export function BasicTool(props: BasicToolProps) {
    - + diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 9f713091651..77d9a8c56a1 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -2123,13 +2123,14 @@ ToolRegistry.register({ (
    - +
    diff --git a/packages/web/src/content/docs/ar/go.mdx b/packages/web/src/content/docs/ar/go.mdx index 0597f3adf18..5698d427244 100644 --- a/packages/web/src/content/docs/ar/go.mdx +++ b/packages/web/src/content/docs/ar/go.mdx @@ -64,6 +64,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** قد تتغير قائمة النماذج مع استمرارنا في اختبار نماذج جديدة وإضافتها. @@ -87,7 +88,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -98,6 +99,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | تستند التقديرات إلى أنماط الطلبات المرصودة: @@ -112,6 +114,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال - Qwen3.7 Max — ‏420 input، و66,000 cached، و200 output tokens لكل طلب - Qwen3.7 Plus — ‏500 input، و57,000 cached، و190 output tokens لكل طلب - Qwen3.6 Plus — ‏500 input، و57,000 cached، و190 output tokens لكل طلب +- Hy3 — ‏830 input، و71,500 cached، و295 output tokens لكل طلب - MiMo-V2.5 — ‏830 input، و71,500 cached، و295 output tokens لكل طلب - MiMo-V2.5-Pro — ‏790 input، و86,000 cached، و305 output tokens لكل طلب @@ -137,6 +140,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | يمكنك تتبّع استخدامك الحالي في **console**. @@ -188,6 +192,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | يستخدم [model id](/docs/config/#models) في إعدادات OpenCode لديك التنسيق `opencode-go/`. على سبيل المثال، بالنسبة إلى Kimi K3، ستستخدم `opencode-go/kimi-k3` في إعداداتك. diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx index 26b5575a013..c9ea860d353 100644 --- a/packages/web/src/content/docs/bs/go.mdx +++ b/packages/web/src/content/docs/bs/go.mdx @@ -74,6 +74,7 @@ Trenutna lista modela uključuje: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Lista modela se može mijenjati dok testiramo i dodajemo nove. @@ -97,7 +98,7 @@ Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca kori | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -108,6 +109,7 @@ Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca kori | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Procjene se zasnivaju na zapaženim obrascima zahtjeva: @@ -122,6 +124,7 @@ Procjene se zasnivaju na zapaženim obrascima zahtjeva: - Qwen3.7 Max — 420 ulaznih, 66,000 keširanih, 200 izlaznih tokena po zahtjevu - Qwen3.7 Plus — 500 ulaznih, 57,000 keširanih, 190 izlaznih tokena po zahtjevu - Qwen3.6 Plus — 500 ulaznih, 57,000 keširanih, 190 izlaznih tokena po zahtjevu +- Hy3 — 830 ulaznih, 71,500 keširanih, 295 izlaznih tokena po zahtjevu - MiMo-V2.5 — 830 ulaznih, 71,500 keširanih, 295 izlaznih tokena po zahtjevu - MiMo-V2.5-Pro — 790 ulaznih, 86,000 keširanih, 305 izlaznih tokena po zahtjevu @@ -147,6 +150,7 @@ Procjene se također zasnivaju na sljedećim cijenama po 1M tokena i mjesečnoj | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Svoju trenutnu potrošnju možete pratiti u **konzoli**. @@ -200,6 +204,7 @@ Također možete pristupiti Go modelima putem sljedećih API endpointa. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [Model id](/docs/config/#models) u vašoj OpenCode konfiguraciji koristi format `opencode-go/`. Na primjer, za Kimi K3, koristili biste diff --git a/packages/web/src/content/docs/da/go.mdx b/packages/web/src/content/docs/da/go.mdx index 280891deeed..4256f4afad0 100644 --- a/packages/web/src/content/docs/da/go.mdx +++ b/packages/web/src/content/docs/da/go.mdx @@ -74,6 +74,7 @@ Den nuværende liste over modeller inkluderer: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Listen over modeller kan ændre sig, efterhånden som vi tester og tilføjer nye. @@ -97,7 +98,7 @@ Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-fo | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -108,6 +109,7 @@ Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-fo | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Estimaterne er baseret på observerede anmodningsmønstre: @@ -122,6 +124,7 @@ Estimaterne er baseret på observerede anmodningsmønstre: - Qwen3.7 Max — 420 input, 66.000 cachelagrede, 200 output-tokens pr. anmodning - Qwen3.7 Plus — 500 input, 57.000 cachelagrede, 190 output-tokens pr. anmodning - Qwen3.6 Plus — 500 input, 57.000 cachelagrede, 190 output-tokens pr. anmodning +- Hy3 — 830 input, 71.500 cachelagrede, 295 output-tokens pr. anmodning - MiMo-V2.5 — 830 input, 71.500 cachelagrede, 295 output-tokens pr. anmodning - MiMo-V2.5-Pro — 790 input, 86.000 cachelagrede, 305 output-tokens pr. anmodning @@ -147,6 +150,7 @@ Estimaterne er også baseret på følgende priser pr. 1M tokens og det månedlig | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Du kan spore dit nuværende forbrug i **konsollen**. @@ -200,6 +204,7 @@ Du kan også få adgang til Go-modeller gennem følgende API-endpoints. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Dit [model id](/docs/config/#models) i din OpenCode config bruger formatet `opencode-go/`. For eksempel for Kimi K3, vil du diff --git a/packages/web/src/content/docs/de/go.mdx b/packages/web/src/content/docs/de/go.mdx index 2bb3f0b7429..3de5f5f786f 100644 --- a/packages/web/src/content/docs/de/go.mdx +++ b/packages/web/src/content/docs/de/go.mdx @@ -66,6 +66,7 @@ Die aktuelle Liste der Modelle umfasst: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Die Liste der Modelle kann sich ändern, während wir neue testen und hinzufügen. @@ -89,7 +90,7 @@ Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf ty | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -100,6 +101,7 @@ Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf ty | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Die Schätzungen basieren auf beobachteten Anfragemustern: @@ -114,6 +116,7 @@ Die Schätzungen basieren auf beobachteten Anfragemustern: - Qwen3.7 Max — 420 Input-, 66.000 Cached-, 200 Output-Tokens pro Anfrage - Qwen3.7 Plus — 500 Input-, 57.000 Cached-, 190 Output-Tokens pro Anfrage - Qwen3.6 Plus — 500 Input-, 57.000 Cached-, 190 Output-Tokens pro Anfrage +- Hy3 — 830 Input-, 71.500 Cached-, 295 Output-Tokens pro Anfrage - MiMo-V2.5 — 830 Input-, 71.500 Cached-, 295 Output-Tokens pro Anfrage - MiMo-V2.5-Pro — 790 Input-, 86.000 Cached-, 305 Output-Tokens pro Anfrage @@ -139,6 +142,7 @@ Die Schätzungen basieren außerdem auf den folgenden Preisen pro 1M Tokens und | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Du kannst deine aktuelle Nutzung in der **Console** verfolgen. @@ -190,6 +194,7 @@ Du kannst auf die Go-Modelle auch über die folgenden API-Endpunkte zugreifen. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Die [Modell-ID](/docs/config/#models) in deiner OpenCode Config verwendet das Format `opencode-go/`. Für Kimi K3 würdest du beispielsweise `opencode-go/kimi-k3` in deiner Config verwenden. diff --git a/packages/web/src/content/docs/es/go.mdx b/packages/web/src/content/docs/es/go.mdx index 4aa1ca46e3f..de028033621 100644 --- a/packages/web/src/content/docs/es/go.mdx +++ b/packages/web/src/content/docs/es/go.mdx @@ -74,6 +74,7 @@ La lista actual de modelos incluye: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** La lista de modelos puede cambiar a medida que probamos y agregamos otros nuevos. @@ -97,7 +98,7 @@ La siguiente tabla proporciona una cantidad estimada de peticiones basada en los | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -108,6 +109,7 @@ La siguiente tabla proporciona una cantidad estimada de peticiones basada en los | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Las estimaciones se basan en los patrones de peticiones observados: @@ -122,6 +124,7 @@ Las estimaciones se basan en los patrones de peticiones observados: - Qwen3.7 Max — 420 tokens de entrada, 66,000 en caché, 200 tokens de salida por petición - Qwen3.7 Plus — 500 tokens de entrada, 57,000 en caché, 190 tokens de salida por petición - Qwen3.6 Plus — 500 tokens de entrada, 57,000 en caché, 190 tokens de salida por petición +- Hy3 — 830 tokens de entrada, 71,500 en caché, 295 tokens de salida por petición - MiMo-V2.5 — 830 tokens de entrada, 71,500 en caché, 295 tokens de salida por petición - MiMo-V2.5-Pro — 790 tokens de entrada, 86,000 en caché, 305 tokens de salida por petición @@ -147,6 +150,7 @@ Las estimaciones también se basan en los siguientes precios por 1M tokens y en | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Puedes realizar un seguimiento de tu uso actual en la **consola**. @@ -200,6 +204,7 @@ También puedes acceder a los modelos de Go a través de los siguientes endpoint | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | El [ID del modelo](/docs/config/#models) en tu configuración de OpenCode usa el formato `opencode-go/`. Por ejemplo, para Kimi K3, usarías diff --git a/packages/web/src/content/docs/fr/go.mdx b/packages/web/src/content/docs/fr/go.mdx index 95849616b0b..fe1139d389f 100644 --- a/packages/web/src/content/docs/fr/go.mdx +++ b/packages/web/src/content/docs/fr/go.mdx @@ -64,6 +64,7 @@ La liste actuelle des modèles comprend : - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** La liste des modèles peut changer au fur et à mesure que nous en testons et en ajoutons de nouveaux. @@ -87,7 +88,7 @@ Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur d | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -98,6 +99,7 @@ Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur d | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Les estimations sont basées sur les schémas de requêtes observés : @@ -112,6 +114,7 @@ Les estimations sont basées sur les schémas de requêtes observés : - Qwen3.7 Max — 420 tokens en entrée, 66,000 en cache, 200 tokens en sortie par requête - Qwen3.7 Plus — 500 tokens en entrée, 57,000 en cache, 190 tokens en sortie par requête - Qwen3.6 Plus — 500 tokens en entrée, 57,000 en cache, 190 tokens en sortie par requête +- Hy3 — 830 tokens en entrée, 71,500 en cache, 295 tokens en sortie par requête - MiMo-V2.5 — 830 tokens en entrée, 71,500 en cache, 295 tokens en sortie par requête - MiMo-V2.5-Pro — 790 tokens en entrée, 86,000 en cache, 305 tokens en sortie par requête @@ -137,6 +140,7 @@ Les estimations sont également basées sur les prix suivants par 1M tokens et s | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Vous pouvez suivre votre utilisation actuelle dans la **console**. @@ -188,6 +192,7 @@ Vous pouvez également accéder aux modèles Go via les points de terminaison d' | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | L'[ID de modèle](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode-go/`. Par exemple, pour Kimi K3, vous utiliseriez `opencode-go/kimi-k3` dans votre configuration. diff --git a/packages/web/src/content/docs/go.mdx b/packages/web/src/content/docs/go.mdx index 8c46464086f..bbd4225b9fa 100644 --- a/packages/web/src/content/docs/go.mdx +++ b/packages/web/src/content/docs/go.mdx @@ -74,6 +74,7 @@ The current list of models includes: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** The list of models may change as we test and add new ones. @@ -97,7 +98,7 @@ The table below provides an estimated request count based on typical Go usage pa | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -108,6 +109,7 @@ The table below provides an estimated request count based on typical Go usage pa | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | The estimates are based on observed request patterns: @@ -124,6 +126,7 @@ The estimates are based on observed request patterns: - Qwen3.7 Max — 420 input, 66,000 cached, 200 output tokens per request - Qwen3.7 Plus — 500 input, 57,000 cached, 190 output tokens per request - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens per request +- Hy3 — 830 input, 71,500 cached, 295 output tokens per request The estimates are also based on the following prices per 1M tokens and the monthly usage included with each model: @@ -147,6 +150,7 @@ The estimates are also based on the following prices per 1M tokens and the month | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | You can track your current usage in the **console**. @@ -200,6 +204,7 @@ You can also access Go models through the following API endpoints. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | The [model id](/docs/config/#models) in your OpenCode config uses the format `opencode-go/`. For example, for Kimi K3, you would diff --git a/packages/web/src/content/docs/it/go.mdx b/packages/web/src/content/docs/it/go.mdx index 65369e1e868..26c459f4569 100644 --- a/packages/web/src/content/docs/it/go.mdx +++ b/packages/web/src/content/docs/it/go.mdx @@ -72,6 +72,7 @@ L'elenco attuale dei modelli include: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** L'elenco dei modelli potrebbe cambiare man mano che ne testiamo e aggiungiamo di nuovi. @@ -95,7 +96,7 @@ La tabella seguente fornisce una stima del conteggio delle richieste in base a p | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -106,6 +107,7 @@ La tabella seguente fornisce una stima del conteggio delle richieste in base a p | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Le stime si basano sui pattern di richieste osservati: @@ -120,6 +122,7 @@ Le stime si basano sui pattern di richieste osservati: - Qwen3.7 Max — 420 di input, 66.000 in cache, 200 token di output per richiesta - Qwen3.7 Plus — 500 di input, 57.000 in cache, 190 token di output per richiesta - Qwen3.6 Plus — 500 di input, 57.000 in cache, 190 token di output per richiesta +- Hy3 — 830 di input, 71.500 in cache, 295 token di output per richiesta - MiMo-V2.5 — 830 di input, 71.500 in cache, 295 token di output per richiesta - MiMo-V2.5-Pro — 790 di input, 86.000 in cache, 305 token di output per richiesta @@ -145,6 +148,7 @@ Le stime si basano anche sui seguenti prezzi per 1M token e sull'utilizzo mensil | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Puoi monitorare il tuo utilizzo attuale nella **console**. @@ -198,6 +202,7 @@ Puoi anche accedere ai modelli Go tramite i seguenti endpoint API. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Il [model id](/docs/config/#models) nella tua OpenCode config utilizza il formato `opencode-go/`. Ad esempio, per Kimi K3, useresti diff --git a/packages/web/src/content/docs/ja/go.mdx b/packages/web/src/content/docs/ja/go.mdx index 8bb9139e83a..f2e95659a6f 100644 --- a/packages/web/src/content/docs/ja/go.mdx +++ b/packages/web/src/content/docs/ja/go.mdx @@ -64,6 +64,7 @@ OpenCode Goをサブスクライブできるのは、1つのワークスペー - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** 新しいモデルをテストして追加するにつれて、モデルのリストは変更される場合があります。 @@ -87,7 +88,7 @@ OpenCode Goには以下の制限が含まれています: | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -98,6 +99,7 @@ OpenCode Goには以下の制限が含まれています: | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | 推定値は、観測されたリクエストパターンに基づいています: @@ -112,6 +114,7 @@ OpenCode Goには以下の制限が含まれています: - Qwen3.7 Max — リクエストあたり 入力 420トークン、キャッシュ 66,000トークン、出力 200トークン - Qwen3.7 Plus — リクエストあたり 入力 500トークン、キャッシュ 57,000トークン、出力 190トークン - Qwen3.6 Plus — リクエストあたり 入力 500トークン、キャッシュ 57,000トークン、出力 190トークン +- Hy3 — リクエストあたり 入力 830トークン、キャッシュ 71,500トークン、出力 295トークン - MiMo-V2.5 — リクエストあたり 入力 830トークン、キャッシュ 71,500トークン、出力 295トークン - MiMo-V2.5-Pro — リクエストあたり 入力 790トークン、キャッシュ 86,000トークン、出力 305トークン @@ -137,6 +140,7 @@ OpenCode Goには以下の制限が含まれています: | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | 現在の利用状況は**コンソール**で追跡できます。 @@ -188,6 +192,7 @@ Goでは月額$10を支払い、その6倍の利用枠を提供することを | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode設定の[model id](/docs/config/#models)は、`opencode-go/`という形式を使用します。たとえば、Kimi K3の場合は、設定で`opencode-go/kimi-k3`を使用します。 diff --git a/packages/web/src/content/docs/ko/go.mdx b/packages/web/src/content/docs/ko/go.mdx index 02f88d2828e..d03198ce4f1 100644 --- a/packages/web/src/content/docs/ko/go.mdx +++ b/packages/web/src/content/docs/ko/go.mdx @@ -64,6 +64,7 @@ workspace당 한 명의 멤버만 OpenCode Go를 구독할 수 있습니다. - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** 새로운 모델을 테스트하고 추가함에 따라 이 목록은 변경될 수 있습니다. @@ -87,7 +88,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -98,6 +99,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | 이 예상치는 관찰된 요청 패턴을 기준으로 합니다. @@ -112,6 +114,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. - Qwen3.7 Max — 요청당 입력 420, 캐시 66,000, 출력 토큰 200 - Qwen3.7 Plus — 요청당 입력 500, 캐시 57,000, 출력 토큰 190 - Qwen3.6 Plus — 요청당 입력 500, 캐시 57,000, 출력 토큰 190 +- Hy3 — 요청당 입력 830, 캐시 71,500, 출력 토큰 295 - MiMo-V2.5 — 요청당 입력 830, 캐시 71,500, 출력 토큰 295 - MiMo-V2.5-Pro — 요청당 입력 790, 캐시 86,000, 출력 토큰 305 @@ -137,6 +140,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | 현재 사용량은 **console**에서 확인할 수 있습니다. @@ -188,6 +192,7 @@ Go에서는 월 $10를 지불하며, 저희는 그 6배의 사용량을 제공 | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode config의 [model id](/docs/config/#models)는 `opencode-go/` 형식을 사용합니다. 예를 들어 Kimi K3의 경우 config에서 `opencode-go/kimi-k3`를 사용하면 됩니다. diff --git a/packages/web/src/content/docs/nb/go.mdx b/packages/web/src/content/docs/nb/go.mdx index 60be1cc7bbb..c63d007a80a 100644 --- a/packages/web/src/content/docs/nb/go.mdx +++ b/packages/web/src/content/docs/nb/go.mdx @@ -74,6 +74,7 @@ Den nåværende listen over modeller inkluderer: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Listen over modeller kan endres etter hvert som vi tester og legger til nye. @@ -97,7 +98,7 @@ Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksm | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -108,6 +109,7 @@ Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksm | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Estimatene er basert på observerte forespørselsmønstre: @@ -122,6 +124,7 @@ Estimatene er basert på observerte forespørselsmønstre: - Qwen3.7 Max — 420 input, 66 000 bufret, 200 output-tokens per forespørsel - Qwen3.7 Plus — 500 input, 57 000 bufret, 190 output-tokens per forespørsel - Qwen3.6 Plus — 500 input, 57 000 bufret, 190 output-tokens per forespørsel +- Hy3 — 830 input, 71 500 bufret, 295 output-tokens per forespørsel - MiMo-V2.5 — 830 input, 71 500 bufret, 295 output-tokens per forespørsel - MiMo-V2.5-Pro — 790 input, 86 000 bufret, 305 output-tokens per forespørsel @@ -147,6 +150,7 @@ Estimatene er også basert på følgende priser per 1M tokens og den månedlige | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Du kan spore din nåværende bruk i **konsollen**. @@ -200,6 +204,7 @@ Du kan også få tilgang til Go-modeller gjennom følgende API-endepunkter. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [Modell-ID-en](/docs/config/#models) i din OpenCode-konfigurasjon bruker formatet `opencode-go/`. For eksempel, for Kimi K3, vil du diff --git a/packages/web/src/content/docs/pl/go.mdx b/packages/web/src/content/docs/pl/go.mdx index 4a867bf0e72..3f542a924e5 100644 --- a/packages/web/src/content/docs/pl/go.mdx +++ b/packages/web/src/content/docs/pl/go.mdx @@ -68,6 +68,7 @@ Obecna lista modeli obejmuje: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Lista modeli może ulec zmianie w miarę testowania i dodawania nowych. @@ -91,7 +92,7 @@ Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -102,6 +103,7 @@ Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Szacunki te opierają się na zaobserwowanych wzorcach żądań: @@ -116,6 +118,7 @@ Szacunki te opierają się na zaobserwowanych wzorcach żądań: - Qwen3.7 Max — 420 tokenów wejściowych, 66 000 w pamięci podręcznej, 200 tokenów wyjściowych na żądanie - Qwen3.7 Plus — 500 tokenów wejściowych, 57 000 w pamięci podręcznej, 190 tokenów wyjściowych na żądanie - Qwen3.6 Plus — 500 tokenów wejściowych, 57 000 w pamięci podręcznej, 190 tokenów wyjściowych na żądanie +- Hy3 — 830 tokenów wejściowych, 71 500 w pamięci podręcznej, 295 tokenów wyjściowych na żądanie - MiMo-V2.5 — 830 tokenów wejściowych, 71 500 w pamięci podręcznej, 295 tokenów wyjściowych na żądanie - MiMo-V2.5-Pro — 790 tokenów wejściowych, 86 000 w pamięci podręcznej, 305 tokenów wyjściowych na żądanie @@ -141,6 +144,7 @@ Szacunki opierają się również na następujących cenach za 1M tokenów oraz | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Możesz śledzić swoje bieżące zużycie w **konsoli**. @@ -192,6 +196,7 @@ Możesz również uzyskać dostęp do modeli Go za pośrednictwem następującyc | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode używa formatu `opencode-go/`. Na przykład dla Kimi K3 należy użyć diff --git a/packages/web/src/content/docs/pt-br/go.mdx b/packages/web/src/content/docs/pt-br/go.mdx index 96c1addfcc9..def6efd471d 100644 --- a/packages/web/src/content/docs/pt-br/go.mdx +++ b/packages/web/src/content/docs/pt-br/go.mdx @@ -74,6 +74,7 @@ A lista atual de modelos inclui: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** A lista de modelos pode mudar conforme testamos e adicionamos novos. @@ -97,7 +98,7 @@ A tabela abaixo fornece uma contagem estimada de requisições com base nos padr | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -108,6 +109,7 @@ A tabela abaixo fornece uma contagem estimada de requisições com base nos padr | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | As estimativas se baseiam nos padrões de requisições observados: @@ -122,6 +124,7 @@ As estimativas se baseiam nos padrões de requisições observados: - Qwen3.7 Max — 420 tokens de entrada, 66.000 em cache, 200 tokens de saída por requisição - Qwen3.7 Plus — 500 tokens de entrada, 57.000 em cache, 190 tokens de saída por requisição - Qwen3.6 Plus — 500 tokens de entrada, 57.000 em cache, 190 tokens de saída por requisição +- Hy3 — 830 tokens de entrada, 71.500 em cache, 295 tokens de saída por requisição - MiMo-V2.5 — 830 tokens de entrada, 71.500 em cache, 295 tokens de saída por requisição - MiMo-V2.5-Pro — 790 tokens de entrada, 86.000 em cache, 305 tokens de saída por requisição @@ -147,6 +150,7 @@ As estimativas também se baseiam nos seguintes preços por 1M tokens e no uso m | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Você pode acompanhar o seu uso atual no **console**. @@ -200,6 +204,7 @@ Você também pode acessar os modelos do Go através dos seguintes endpoints de | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | O [ID do modelo](/docs/config/#models) na sua configuração do OpenCode usa o formato `opencode-go/`. Por exemplo, para o Kimi K3, você usaria diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx index 62305fbdb69..0bbd43369ba 100644 --- a/packages/web/src/content/docs/ru/go.mdx +++ b/packages/web/src/content/docs/ru/go.mdx @@ -74,6 +74,7 @@ OpenCode Go работает так же, как и любой другой пр - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Список моделей может меняться по мере того, как мы тестируем и добавляем новые. @@ -97,7 +98,7 @@ OpenCode Go включает следующие лимиты: | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -108,6 +109,7 @@ OpenCode Go включает следующие лимиты: | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Эти оценки основаны на наблюдаемых показателях запросов: @@ -122,6 +124,7 @@ OpenCode Go включает следующие лимиты: - Qwen3.7 Max — 420 входных, 66,000 кешированных, 200 выходных токенов на запрос - Qwen3.7 Plus — 500 входных, 57,000 кешированных, 190 выходных токенов на запрос - Qwen3.6 Plus — 500 входных, 57,000 кешированных, 190 выходных токенов на запрос +- Hy3 — 830 входных, 71,500 кешированных, 295 выходных токенов на запрос - MiMo-V2.5 — 830 входных, 71,500 кешированных, 295 выходных токенов на запрос - MiMo-V2.5-Pro — 790 входных, 86,000 кешированных, 305 выходных токенов на запрос @@ -147,6 +150,7 @@ OpenCode Go включает следующие лимиты: | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Вы можете отслеживать текущее использование в **консоли**. @@ -200,6 +204,7 @@ OpenCode Go включает следующие лимиты: | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID модели](/docs/config/#models) в вашем конфиге OpenCode использует формат `opencode-go/`. Например, для Kimi K3 вам нужно diff --git a/packages/web/src/content/docs/th/go.mdx b/packages/web/src/content/docs/th/go.mdx index 36036426da4..48b0c05bf1e 100644 --- a/packages/web/src/content/docs/th/go.mdx +++ b/packages/web/src/content/docs/th/go.mdx @@ -64,6 +64,7 @@ OpenCode Go ทำงานเหมือนกับผู้ให้บร - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** รายชื่อโมเดลอาจมีการเปลี่ยนแปลงเมื่อเราทำการทดสอบและเพิ่มโมเดลใหม่ๆ @@ -87,7 +88,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -98,6 +99,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | การประมาณการนี้อ้างอิงจากรูปแบบการใช้งาน request ที่สังเกตพบ: @@ -112,6 +114,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: - Qwen3.7 Max — 420 input, 66,000 cached, 200 output tokens ต่อ request - Qwen3.7 Plus — 500 input, 57,000 cached, 190 output tokens ต่อ request - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens ต่อ request +- Hy3 — 830 input, 71,500 cached, 295 output tokens ต่อ request - MiMo-V2.5 — 830 input, 71,500 cached, 295 output tokens ต่อ request - MiMo-V2.5-Pro — 790 input, 86,000 cached, 305 output tokens ต่อ request @@ -137,6 +140,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | คุณสามารถติดตามการใช้งานปัจจุบันของคุณได้ใน **console** @@ -188,6 +192,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) ใน OpenCode config ของคุณจะใช้รูปแบบ `opencode-go/` ตัวอย่างเช่น สำหรับ Kimi K3 คุณจะใช้ `opencode-go/kimi-k3` ใน config ของคุณ diff --git a/packages/web/src/content/docs/tr/go.mdx b/packages/web/src/content/docs/tr/go.mdx index e24ead2959f..0611ae31b7b 100644 --- a/packages/web/src/content/docs/tr/go.mdx +++ b/packages/web/src/content/docs/tr/go.mdx @@ -64,6 +64,7 @@ Mevcut model listesi şunları içerir: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Test edip yenilerini ekledikçe model listesi değişebilir. @@ -87,7 +88,7 @@ Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek say | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -98,6 +99,7 @@ Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek say | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Tahminler, gözlemlenen istek modellerine dayanır: @@ -112,6 +114,7 @@ Tahminler, gözlemlenen istek modellerine dayanır: - Qwen3.7 Max — İstek başına 420 girdi, 66.000 önbelleğe alınmış, 200 çıktı token'ı - Qwen3.7 Plus — İstek başına 500 girdi, 57.000 önbelleğe alınmış, 190 çıktı token'ı - Qwen3.6 Plus — İstek başına 500 girdi, 57.000 önbelleğe alınmış, 190 çıktı token'ı +- Hy3 — İstek başına 830 girdi, 71.500 önbelleğe alınmış, 295 çıktı token'ı - MiMo-V2.5 — İstek başına 830 girdi, 71.500 önbelleğe alınmış, 295 çıktı token'ı - MiMo-V2.5-Pro — İstek başına 790 girdi, 86.000 önbelleğe alınmış, 305 çıktı token'ı @@ -137,6 +140,7 @@ Tahminler ayrıca 1M token başına aşağıdaki fiyatlara ve her modelle birlik | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Mevcut kullanımınızı **konsoldan** takip edebilirsiniz. @@ -188,6 +192,7 @@ Go modellerine aşağıdaki API uç noktaları aracılığıyla da erişebilirsi | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode yapılandırmanızdaki [model id](/docs/config/#models) formatı `opencode-go/` şeklindedir. Örneğin, Kimi K3 için yapılandırmanızda `opencode-go/kimi-k3` kullanmalısınız. diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx index 604b9101593..873b3a02241 100644 --- a/packages/web/src/content/docs/zh-cn/go.mdx +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -64,6 +64,7 @@ OpenCode Go 的工作方式与 OpenCode 中的其他提供商一样。 - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** 随着我们进行测试和添加新模型,该列表可能会发生变化。 @@ -87,7 +88,7 @@ OpenCode Go 包含以下限制: | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -98,6 +99,7 @@ OpenCode Go 包含以下限制: | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | 预估值基于观察到的请求模式: @@ -114,6 +116,7 @@ OpenCode Go 包含以下限制: - Qwen3.7 Max — 每次请求 420 个输入 token,66,000 个缓存 token,200 个输出 token - Qwen3.7 Plus — 每次请求 500 个输入 token,57,000 个缓存 token,190 个输出 token - Qwen3.6 Plus — 每次请求 500 个输入 token,57,000 个缓存 token,190 个输出 token +- Hy3 — 每次请求 830 个输入 token,71,500 个缓存 token,295 个输出 token 预估值还基于以下每 1M tokens 的价格以及每个模型包含的每月使用额度: @@ -137,6 +140,7 @@ OpenCode Go 包含以下限制: | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | 你可以在 **控制台** 中跟踪你当前的使用情况。 @@ -188,6 +192,7 @@ OpenCode Go 包含以下限制: | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | 你的 OpenCode 配置中的 [模型 ID](/docs/config/#models) 使用 `opencode-go/` 格式。例如,对于 Kimi K3,你将在配置中使用 `opencode-go/kimi-k3`。 diff --git a/packages/web/src/content/docs/zh-tw/go.mdx b/packages/web/src/content/docs/zh-tw/go.mdx index 6434504a3e0..691abaa2a92 100644 --- a/packages/web/src/content/docs/zh-tw/go.mdx +++ b/packages/web/src/content/docs/zh-tw/go.mdx @@ -64,6 +64,7 @@ OpenCode Go 的運作方式與 OpenCode 中的任何其他供應商相同。 - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** 隨著我們測試並加入新模型,模型清單可能會有所變動。 @@ -87,7 +88,7 @@ OpenCode Go 包含以下限制: | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | @@ -98,6 +99,7 @@ OpenCode Go 包含以下限制: | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | 這些預估值是基於觀察到的請求模式: @@ -112,6 +114,7 @@ OpenCode Go 包含以下限制: - Qwen3.7 Max — 每次請求 420 個輸入 token、66,000 個快取 token、200 個輸出 token - Qwen3.7 Plus — 每次請求 500 個輸入 token、57,000 個快取 token、190 個輸出 token - Qwen3.6 Plus — 每次請求 500 個輸入 token、57,000 個快取 token、190 個輸出 token +- Hy3 — 每次請求 830 個輸入 token、71,500 個快取 token、295 個輸出 token - MiMo-V2.5 — 每次請求 830 個輸入 token、71,500 個快取 token、295 個輸出 token - MiMo-V2.5-Pro — 每次請求 790 個輸入 token、86,000 個快取 token、305 個輸出 token @@ -137,6 +140,7 @@ OpenCode Go 包含以下限制: | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | 您可以在 **console** 中追蹤您目前的使用量。 @@ -188,6 +192,7 @@ OpenCode Go 包含以下限制: | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | 您的 OpenCode 設定中的 [model id](/docs/config/#models) 使用 `opencode-go/` 格式。例如,Kimi K3 在設定中應使用 `opencode-go/kimi-k3`。 From 4e067a20142c8081574f1143eda34e68f1a3770b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:19:06 -0500 Subject: [PATCH 03/30] test(core): remove duplicate patch integration tests (#38389) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/core/test/tool-patch.test.ts | 163 -------------------------- 1 file changed, 163 deletions(-) diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index f43b49342c3..86ae95e1837 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -387,17 +387,6 @@ describe("PatchTool", () => { ), ) - it.live("updates an empty file", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "empty.txt") - yield* Effect.promise(() => fs.writeFile(target, "")) - yield* executeTool(registry, call("*** Begin Patch\n*** Update File: empty.txt\n@@\n+First line\n*** End Patch")) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("First line\n") - }), - ), - ) - it.live("rejects deleting a directory", () => withTempTool((directory, registry) => Effect.gen(function* () { @@ -410,40 +399,6 @@ describe("PatchTool", () => { ), ) - it.live("supports an end-of-file anchor", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "tail.txt") - yield* Effect.promise(() => fs.writeFile(target, "first\nsecond")) - yield* executeTool( - registry, - call( - "*** Begin Patch\n*** Update File: tail.txt\n@@\n first\n-second\n+second updated\n*** End of File\n*** End Patch", - ), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first\nsecond updated\n") - }), - ), - ) - - it.live("applies an end-of-file chunk to the final duplicate", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "duplicates.txt") - yield* Effect.promise(() => fs.writeFile(target, "marker\nend\nmiddle\nmarker\nend\n")) - yield* executeTool( - registry, - call( - "*** Begin Patch\n*** Update File: duplicates.txt\n@@\n-marker\n-end\n+marker changed\n+end\n*** End of File\n*** End Patch", - ), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe( - "marker\nend\nmiddle\nmarker changed\nend\n", - ) - }), - ), - ) - it.live("rejects a missing second chunk context", () => withTempTool((directory, registry) => Effect.gen(function* () { @@ -513,20 +468,6 @@ describe("PatchTool", () => { ), ) - it.live("applies multiple hunks to one file", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "multi.txt") - yield* Effect.promise(() => fs.writeFile(target, "a\nb\nc\nd\n")) - yield* executeTool( - registry, - call("*** Begin Patch\n*** Update File: multi.txt\n@@\n-b\n+B\n@@\n-d\n+D\n*** End Patch"), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nB\nc\nD\n") - }), - ), - ) - it.live("applies successive update operations to one file", () => withTempTool((directory, registry) => Effect.gen(function* () { @@ -566,110 +507,6 @@ describe("PatchTool", () => { ), ) - it.live("appends a trailing newline on update", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "no-newline.txt") - yield* Effect.promise(() => fs.writeFile(target, "no newline at end")) - yield* executeTool( - registry, - call( - "*** Begin Patch\n*** Update File: no-newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch", - ), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first line\nsecond line\n") - }), - ), - ) - - it.live("disambiguates change context with an @@ header", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "context.txt") - yield* Effect.promise(() => fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n")) - yield* executeTool( - registry, - call("*** Begin Patch\n*** Update File: context.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe( - "fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n", - ) - }), - ), - ) - - it.live("parses a heredoc-wrapped patch", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - yield* executeTool( - registry, - call("cat <<'EOF'\n*** Begin Patch\n*** Add File: heredoc.txt\n+with cat\n*** End Patch\nEOF"), - ) - expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe( - "with cat\n", - ) - }), - ), - ) - - it.live("parses a heredoc-wrapped patch without cat", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - yield* executeTool( - registry, - call("< fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe( - "without cat\n", - ) - }), - ), - ) - - it.live("matches with trailing whitespace differences", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "trailing.txt") - yield* Effect.promise(() => fs.writeFile(target, "line1 \nline2\nline3 \n")) - yield* executeTool( - registry, - call("*** Begin Patch\n*** Update File: trailing.txt\n@@\n-line2\n+changed\n*** End Patch"), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1 \nchanged\nline3 \n") - }), - ), - ) - - it.live("matches with leading whitespace differences", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "leading.txt") - yield* Effect.promise(() => fs.writeFile(target, " line1\nline2\n line3\n")) - yield* executeTool( - registry, - call("*** Begin Patch\n*** Update File: leading.txt\n@@\n-line2\n+changed\n*** End Patch"), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(" line1\nchanged\n line3\n") - }), - ), - ) - - it.live("matches with Unicode punctuation differences", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "unicode.txt") - yield* Effect.promise(() => fs.writeFile(target, "He said “hello”\nsome—dash\nend\n")) - yield* executeTool( - registry, - call( - '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch', - ), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe('He said "hi"\nsome—dash\nend\n') - }), - ), - ) - it.live("rejects an update with missing context", () => withTempTool((directory, registry) => Effect.gen(function* () { From 36979c96419c574a737ad9186863c3718f8115c1 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:50:33 -0500 Subject: [PATCH 04/30] test(core): consolidate provider factory coverage (#38390) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- .../core/test/plugin/provider-alibaba.test.ts | 96 ----------- .../core/test/plugin/provider-cohere.test.ts | 127 -------------- .../test/plugin/provider-deepinfra.test.ts | 161 ------------------ .../core/test/plugin/provider-factory.test.ts | 60 +++++++ .../core/test/plugin/provider-gateway.test.ts | 115 ------------- .../core/test/plugin/provider-groq.test.ts | 122 ------------- .../core/test/plugin/provider-mistral.test.ts | 134 --------------- .../test/plugin/provider-perplexity.test.ts | 127 -------------- .../test/plugin/provider-togetherai.test.ts | 132 -------------- .../core/test/plugin/provider-venice.test.ts | 120 ------------- 10 files changed, 60 insertions(+), 1134 deletions(-) delete mode 100644 packages/core/test/plugin/provider-alibaba.test.ts delete mode 100644 packages/core/test/plugin/provider-cohere.test.ts delete mode 100644 packages/core/test/plugin/provider-deepinfra.test.ts create mode 100644 packages/core/test/plugin/provider-factory.test.ts delete mode 100644 packages/core/test/plugin/provider-gateway.test.ts delete mode 100644 packages/core/test/plugin/provider-groq.test.ts delete mode 100644 packages/core/test/plugin/provider-mistral.test.ts delete mode 100644 packages/core/test/plugin/provider-perplexity.test.ts delete mode 100644 packages/core/test/plugin/provider-togetherai.test.ts delete mode 100644 packages/core/test/plugin/provider-venice.test.ts diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts deleted file mode 100644 index bb7f922e524..00000000000 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" -import { createAlibaba } from "@ai-sdk/alibaba" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* AlibabaPlugin.effect(host) -}) - -describe("AlibabaPlugin", () => { - it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), - modelID: ModelV2.ID.make("qwen"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/alibaba", - options: { name: "alibaba" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("ignores non-Alibaba SDK packages", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), - modelID: ModelV2.ID.make("qwen"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "alibaba" }, - }) - expect(result.sdk).toBeUndefined() - }), - ) - - it.effect("matches the old bundled Alibaba SDK provider naming", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), - modelID: ModelV2.ID.make("qwen"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/alibaba", - options: { name: "custom-alibaba", apiKey: "test" }, - }) - const expected = createAlibaba({ apiKey: "test", ...{ name: "custom-alibaba" } }).languageModel("qwen") - const actual = result.sdk?.languageModel("qwen") - expect(actual?.provider).toBe(expected.provider) - expect(actual?.modelId).toBe(expected.modelId) - }), - ) - - it.effect("uses the default languageModel(modelID) behavior", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const item = ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("qwen-plus"), - package: "aisdk:test-provider", - }) - const result = yield* aisdk.runSDK({ model: item, package: "@ai-sdk/alibaba", options: {} }) - const language = result.sdk?.languageModel(item.modelID ?? item.id) - expect(language?.modelId).toBe("qwen-plus") - expect(language?.provider).toBe("alibaba.chat") - }), - ) -}) diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts deleted file mode 100644 index a4109f74bdb..00000000000 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect, mock } from "bun:test" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" -import { ProviderV2 } from "@opencode-ai/core/provider" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const cohereOptions: Record[] = [] -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* CoherePlugin.effect(host) -}) - -function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -void mock.module("@ai-sdk/cohere", () => ({ - createCohere: (options: Record) => { - cohereOptions.push({ ...options }) - return { - languageModel: (modelID: string) => ({ - modelID, - provider: `${options.name ?? "cohere"}.chat`, - specificationVersion: "v3", - }), - } - }, -})) - -describe("CoherePlugin", () => { - it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const ignored = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), - modelID: ModelV2.ID.make("command"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cohere" }, - }) - expect(ignored.sdk).toBeUndefined() - - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), - modelID: ModelV2.ID.make("command"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/cohere", - options: { name: "cohere" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("uses the model provider ID as the bundled SDK name", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), - modelID: ModelV2.ID.make("command-r-plus"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/cohere", - options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, - }) - - expect(cohereOptions.at(-1)).toEqual({ - name: "custom-cohere", - apiKey: "test", - baseURL: "https://cohere.example", - }) - expect(result.sdk?.languageModel("command-r-plus").provider).toBe("custom-cohere.chat") - }), - ) - - it.effect("leaves language selection to the default languageModel fallback", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - const sdk = fakeSelectorSdk(calls) - yield* addPlugin() - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("command-r-plus"), - package: "aisdk:test-provider", - }), - sdk, - options: {}, - }) - - expect(result.language).toBeUndefined() - expect(calls).toEqual([]) - expect(result.language ?? sdk.languageModel("command-r-plus")).toBeDefined() - expect(calls).toEqual(["languageModel:command-r-plus"]) - }), - ) -}) diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts deleted file mode 100644 index 14c41e550d7..00000000000 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect, mock } from "bun:test" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) -const deepinfraOptions: Record[] = [] -const deepinfraLanguageModels: string[] = [] - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* DeepInfraPlugin.effect(host) -}) - -void mock.module("@ai-sdk/deepinfra", () => ({ - createDeepInfra: (options: Record) => { - const captured = { ...options } - deepinfraOptions.push(captured) - return { - languageModel: (modelID: string) => { - deepinfraLanguageModels.push(modelID) - return { modelID, provider: `${captured.name ?? "deepinfra"}.chat`, specificationVersion: "v3" } - }, - } - }, -})) - -function resetDeepInfraMock() { - deepinfraOptions.length = 0 - deepinfraLanguageModels.length = 0 -} - -describe("DeepInfraPlugin", () => { - it.effect("creates a DeepInfra SDK for @ai-sdk/deepinfra", () => - Effect.gen(function* () { - resetDeepInfraMock() - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("passes the model provider ID as the bundled DeepInfra SDK name", () => - Effect.gen(function* () { - resetDeepInfraMock() - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: "@ai-sdk/deepinfra", - options: { name: "custom-deepinfra", apiKey: "test" }, - }) - expect(result.sdk.languageModel("model").provider).toBe("custom-deepinfra.chat") - expect(deepinfraOptions).toEqual([{ name: "custom-deepinfra", apiKey: "test" }]) - }), - ) - - it.effect("uses the canonical provider ID as the bundled DeepInfra SDK name", () => - Effect.gen(function* () { - resetDeepInfraMock() - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra", apiKey: "test" }, - }) - expect(result.sdk.languageModel("model").provider).toBe("deepinfra.chat") - expect(deepinfraOptions).toEqual([{ name: "deepinfra", apiKey: "test" }]) - }), - ) - - it.effect("matches only the exact bundled DeepInfra package", () => - Effect.gen(function* () { - resetDeepInfraMock() - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const packages = [ - "unmatched-package", - "@ai-sdk/deepinfra-compatible", - "file:///tmp/@ai-sdk/deepinfra-provider.js", - ] - yield* Effect.forEach(packages, (item) => - Effect.gen(function* () { - const ignored = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: item, - options: { name: "deepinfra" }, - }) - expect(ignored.sdk).toBeUndefined() - }), - ) - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }) - expect(result.sdk).toBeDefined() - expect(deepinfraOptions).toEqual([{ name: "deepinfra" }]) - }), - ) - - it.effect("uses the default languageModel selection for DeepInfra models", () => - Effect.gen(function* () { - resetDeepInfraMock() - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const sdkEvent = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct")), - modelID: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }) - const result = yield* aisdk.runLanguage({ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }) - const language = result.language ?? result.sdk.languageModel(result.model.modelID ?? result.model.id) - expect(language.provider).toBe("deepinfra.chat") - expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) - }), - ) -}) diff --git a/packages/core/test/plugin/provider-factory.test.ts b/packages/core/test/plugin/provider-factory.test.ts new file mode 100644 index 00000000000..a884fd0ce0f --- /dev/null +++ b/packages/core/test/plugin/provider-factory.test.ts @@ -0,0 +1,60 @@ +import { expect } from "bun:test" +import { Effect } from "effect" +import { AISDK } from "@opencode-ai/core/aisdk" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" +import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" +import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" +import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" +import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" +import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" +import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" +import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" +import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const modelID = ModelV2.ID.make("test-model") +const options = { name: "custom-provider", apiKey: "test", baseURL: "https://example.test" } +const providers = [ + { id: "alibaba", plugin: AlibabaPlugin, package: "@ai-sdk/alibaba", provider: "alibaba.chat" }, + { id: "cohere", plugin: CoherePlugin, package: "@ai-sdk/cohere", provider: "cohere.chat" }, + { id: "deepinfra", plugin: DeepInfraPlugin, package: "@ai-sdk/deepinfra", provider: "deepinfra.chat" }, + { id: "gateway", plugin: GatewayPlugin, package: "@ai-sdk/gateway", provider: "gateway" }, + { id: "groq", plugin: GroqPlugin, package: "@ai-sdk/groq", provider: "groq.chat" }, + { id: "mistral", plugin: MistralPlugin, package: "@ai-sdk/mistral", provider: "mistral.chat" }, + { id: "perplexity", plugin: PerplexityPlugin, package: "@ai-sdk/perplexity", provider: "perplexity" }, + { id: "togetherai", plugin: TogetherAIPlugin, package: "@ai-sdk/togetherai", provider: "togetherai.chat" }, + { id: "venice", plugin: VenicePlugin, package: "venice-ai-sdk-provider", provider: "custom-provider.chat" }, +] as const + +const it = testEffect(PluginTestLayer) + +providers.forEach((item) => + it.effect(`${item.id} loads only its exact package`, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* item.plugin.effect(host) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make(item.id), modelID), + modelID, + package: ProviderV2.aisdk(item.package), + }) + const matched = yield* aisdk.runSDK({ model, package: item.package, options }) + const ignored = yield* aisdk.runSDK({ model, package: `${item.package}/unsupported`, options }) + const language = matched.sdk?.languageModel(modelID) + + expect({ + provider: language?.provider, + modelID: language?.modelId, + version: language?.specificationVersion, + ignored: ignored.sdk === undefined, + }).toEqual({ provider: item.provider, modelID: "test-model", version: "v3", ignored: true }) + }), + ), +) diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts deleted file mode 100644 index 722bbde9b6f..00000000000 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect, mock } from "bun:test" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const gatewayCalls: Record[] = [] -const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* GatewayPlugin.effect(host) -}) - -mock.module("@ai-sdk/gateway", () => ({ - createGateway(options: Record) { - gatewayCalls.push({ ...options }) - return { - languageModel(modelID: string) { - return { - modelId: modelID, - provider: options.name, - specificationVersion: "v3", - } - }, - } - }, -})) - -describe("GatewayPlugin", () => { - it.effect("creates a Gateway SDK for @ai-sdk/gateway", () => - Effect.gen(function* () { - gatewayCalls.length = 0 - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/gateway", - options: { name: "gateway" }, - }) - expect(result.sdk).toBeDefined() - expect(gatewayCalls).toHaveLength(1) - }), - ) - - it.effect("passes the model providerID as the Gateway SDK name", () => - Effect.gen(function* () { - gatewayCalls.length = 0 - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")), - modelID: ModelV2.ID.make("anthropic/claude-sonnet-4"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/gateway", - options: { name: "vercel", apiKey: "test-key" }, - }) - - expect(gatewayCalls).toEqual([{ name: "vercel", apiKey: "test-key" }]) - expect(result.sdk.languageModel("anthropic/claude-sonnet-4").provider).toBe("vercel") - }), - ) - - it.effect("matches Vercel AI Gateway models by their @ai-sdk/gateway package", () => - Effect.gen(function* () { - gatewayCalls.length = 0 - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - - for (const modelID of vercelGatewayModels) { - const ignored = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), - modelID: ModelV2.ID.make(modelID), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/vercel", - options: { name: "vercel" }, - }) - expect(ignored.sdk).toBeUndefined() - - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), - modelID: ModelV2.ID.make(modelID), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/gateway", - options: { name: "vercel" }, - }) - expect(result.sdk).toBeDefined() - } - - expect(gatewayCalls).toHaveLength(3) - }), - ) -}) diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts deleted file mode 100644 index b8900384b7d..00000000000 --- a/packages/core/test/plugin/provider-groq.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" -import { createGroq } from "@ai-sdk/groq" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* GroqPlugin.effect(host) -}) - -describe("GroqPlugin", () => { - it.effect("creates a Groq SDK for @ai-sdk/groq", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - modelID: ModelV2.ID.make("llama"), - package: "aisdk:@ai-sdk/groq", - }), - package: "@ai-sdk/groq", - options: { name: "groq" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("ignores non-Groq SDK packages", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - modelID: ModelV2.ID.make("llama"), - package: "aisdk:@ai-sdk/groq", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "groq" }, - }) - expect(result.sdk).toBeUndefined() - }), - ) - - it.effect("only matches the bundled @ai-sdk/groq package exactly", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - modelID: ModelV2.ID.make("llama"), - package: "aisdk:@ai-sdk/groq", - }), - package: "@ai-sdk/groq/compat", - options: { name: "groq" }, - }) - expect(result.sdk).toBeUndefined() - }), - ) - - it.effect("matches the old bundled Groq SDK provider naming", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), - modelID: ModelV2.ID.make("llama"), - package: "aisdk:@ai-sdk/groq", - }), - package: "@ai-sdk/groq", - options: { name: "custom-groq", apiKey: "test" }, - }) - const expected = createGroq({ name: "custom-groq", apiKey: "test" } as Parameters[0] & { - name: string - }).languageModel("llama") - const actual = result.sdk?.languageModel("llama") - expect(actual?.provider).toBe(expected.provider) - expect(actual?.modelId).toBe(expected.modelId) - }), - ) - - it.effect("uses the default languageModel(modelID) behavior", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters[0] & { - name: string - }) - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("llama-api"), - package: "aisdk:@ai-sdk/groq", - }), - sdk, - options: { name: "groq", apiKey: "test" }, - }) - const language = result.language ?? sdk.languageModel(result.model.modelID ?? result.model.id) - expect(language.modelId).toBe("llama-api") - expect(language.provider).toBe("groq.chat") - }), - ) -}) diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts deleted file mode 100644 index 182873482cf..00000000000 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { describe, expect } from "bun:test" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* MistralPlugin.effect(host) -}) - -describe("MistralPlugin", () => { - it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - modelID: ModelV2.ID.make("mistral-large"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/mistral", - options: { name: "mistral" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("ignores non-Mistral SDK packages", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - modelID: ModelV2.ID.make("mistral-large"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "mistral" }, - }) - expect(result.sdk).toBeUndefined() - }), - ) - - it.effect("matches the old bundled Mistral SDK provider name for the bundled provider ID", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const providers: string[] = [] - yield* addPlugin() - yield* aisdk.hook.sdk((event) => - Effect.sync(() => { - providers.push(event.sdk.languageModel("mistral-large").provider) - }), - ) - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - modelID: ModelV2.ID.make("mistral-large"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/mistral", - options: { name: "mistral" }, - }) - expect(result.sdk).toBeDefined() - expect(providers).toEqual(["mistral.chat"]) - }), - ) - - it.effect("matches the old bundled Mistral SDK provider name for custom provider IDs", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const providers: string[] = [] - yield* addPlugin() - yield* aisdk.hook.sdk((event) => - Effect.sync(() => { - providers.push(event.sdk.languageModel("mistral-large").provider) - }), - ) - yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), - modelID: ModelV2.ID.make("mistral-large"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/mistral", - options: { name: "custom-mistral" }, - }) - expect(providers).toEqual(["mistral.chat"]) - }), - ) - - it.effect("leaves Mistral language selection on the default sdk.languageModel(modelID) path", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - const sdk = { - languageModel: (id: string) => { - calls.push(`languageModel:${id}`) - return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 - }, - } - yield* addPlugin() - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("mistral-large"), - package: "aisdk:test-provider", - }), - sdk, - options: {}, - }) - const language = result.language ?? sdk.languageModel(result.model.modelID ?? result.model.id) - expect(calls).toEqual(["languageModel:mistral-large"]) - expect(language).toBeDefined() - }), - ) -}) diff --git a/packages/core/test/plugin/provider-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts deleted file mode 100644 index d66f5dd71c4..00000000000 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* PerplexityPlugin.effect(host) -}) - -function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -describe("PerplexityPlugin", () => { - it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - modelID: ModelV2.ID.make("sonar"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/perplexity", - options: { name: "perplexity" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("ignores packages that are not the bundled Perplexity package", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - modelID: ModelV2.ID.make("sonar"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/perplexity-compatible", - options: { name: "perplexity" }, - }) - expect(result.sdk).toBeUndefined() - }), - ) - - it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - modelID: ModelV2.ID.make("sonar"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/perplexity", - options: { name: "perplexity" }, - }) - expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") - }), - ) - - it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), - modelID: ModelV2.ID.make("sonar"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/perplexity", - options: { name: "custom-perplexity" }, - }) - expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") - }), - ) - - it.effect("leaves Perplexity language selection to the default languageModel fallback", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin() - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("sonar"), - package: "aisdk:test-provider", - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }) - expect(calls).toEqual([]) - expect(result.language).toBeUndefined() - }), - ) -}) diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts deleted file mode 100644 index 1fffb03156a..00000000000 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* TogetherAIPlugin.effect(host) -}) - -function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -describe("TogetherAIPlugin", () => { - it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/togetherai", - options: { name: "togetherai" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("matches the old bundled provider package exactly", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const ignored = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "file:///tmp/@ai-sdk/togetherai-provider.js", - options: { name: "togetherai" }, - }) - expect(ignored.sdk).toBeUndefined() - - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/togetherai", - options: { name: "togetherai" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/togetherai", - options: { name: "custom-togetherai" }, - }) - - expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat") - }), - ) - - it.effect("defaults language selection to sdk.languageModel with the model API ID", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin() - - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("togetherai"), - ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), - ), - modelID: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), - package: "aisdk:test-provider", - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }) - - expect(result.language).toBeUndefined() - expect(calls).toEqual([]) - expect( - result.language ?? fakeSelectorSdk(calls).languageModel(result.model.modelID ?? result.model.id), - ).toBeDefined() - expect(calls).toEqual(["languageModel:meta-llama/Llama-3.3-70B-Instruct-Turbo"]) - }), - ) -}) diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts deleted file mode 100644 index 057d2c6ded9..00000000000 --- a/packages/core/test/plugin/provider-venice.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* VenicePlugin.effect(host) -}) - -function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -describe("VenicePlugin", () => { - it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "venice-ai-sdk-provider", - options: { name: "venice" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("uses the model provider ID as the bundled Venice SDK name", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "venice-ai-sdk-provider", - options: { name: "custom-venice", apiKey: "test" }, - }) - expect(result.sdk).toBeDefined() - expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat") - }), - ) - - it.effect("only handles the bundled venice-ai-sdk-provider package", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const similar = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "file:///tmp/venice-ai-sdk-provider.js", - options: { name: "venice" }, - }) - const other = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "venice" }, - }) - expect(similar.sdk).toBeUndefined() - expect(other.sdk).toBeUndefined() - }), - ) - - it.effect("leaves Venice language selection to the default languageModel fallback", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin() - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("alias"), - package: "aisdk:test-provider", - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }) - expect(calls).toEqual([]) - expect(result.language).toBeUndefined() - }), - ) -}) From b6e14b5a7415ad5c92f72a295339fd0564cbe8b1 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 22 Jul 2026 17:56:22 -0400 Subject: [PATCH 05/30] refactor(tui): finish V2 theme migration (#38383) --- .../client/src/promise/generated/types.ts | 4 +--- .../test/fixtures/opencode-v2-openapi.json | 22 +++---------------- packages/core/src/config/agent.ts | 5 +---- packages/core/src/v1/config/agent.ts | 7 ++---- packages/core/test/config/agent.test.ts | 10 ++++++--- packages/core/test/config/config.test.ts | 4 ++-- packages/docs/agents.mdx | 7 +++--- packages/docs/openapi.json | 22 +++---------------- packages/schema/src/agent.ts | 7 +++--- packages/schema/test/contract-hygiene.test.ts | 6 +++++ packages/tui/src/component/bg-pulse.tsx | 8 +++---- packages/tui/src/context/local.tsx | 19 ++-------------- packages/tui/src/context/theme.tsx | 15 +++---------- packages/www/content/docs/(docs)/agents.mdx | 7 +++--- packages/www/public/openapi.json | 22 +++---------------- 15 files changed, 46 insertions(+), 119 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 6c869b0e2e6..966ec22cc03 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -8,8 +8,6 @@ export type ModelRef = { id: string; providerID: string; variant?: string } export type ProviderSettings = { [x: string]: JsonValue } -export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" - export type PermissionV2Effect = "allow" | "deny" | "ask" export type PluginInfo = { id: string } @@ -2005,7 +2003,7 @@ export type AgentInfo = { description?: string mode: "subagent" | "primary" | "all" hidden: boolean - color?: AgentColor + color?: string steps?: number permissions: PermissionV2Ruleset } diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json index 543a4181737..d430f5d21dd 100644 --- a/packages/codemode/test/fixtures/opencode-v2-openapi.json +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -10561,26 +10561,10 @@ "additionalProperties": false }, "Agent.Color": { - "anyOf": [ + "type": "string", + "allOf": [ { - "type": "string", - "allOf": [ - { - "pattern": "^#[0-9a-fA-F]{6}$" - } - ] - }, - { - "type": "string", - "enum": [ - "primary", - "secondary", - "accent", - "success", - "warning", - "error", - "info" - ] + "pattern": "^#[0-9a-fA-F]{6}$" } ] }, diff --git a/packages/core/src/config/agent.ts b/packages/core/src/config/agent.ts index fc5edf51192..075feea2507 100644 --- a/packages/core/src/config/agent.ts +++ b/packages/core/src/config/agent.ts @@ -6,10 +6,7 @@ import { ConfigProvider } from "./provider" import { ConfigModel } from "./model" import { PositiveInt } from "../schema" -export const Color = Schema.Union([ - Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), - Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), -]) +export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) export class Info extends Schema.Class("ConfigV2.Agent")({ model: ConfigModel.Selection.pipe(Schema.optional), diff --git a/packages/core/src/v1/config/agent.ts b/packages/core/src/v1/config/agent.ts index b220bd7ef87..09838a91968 100644 --- a/packages/core/src/v1/config/agent.ts +++ b/packages/core/src/v1/config/agent.ts @@ -4,10 +4,7 @@ import { Schema, SchemaGetter } from "effect" import { PositiveInt } from "../../schema" import { ConfigPermissionV1 } from "./permission" -const Color = Schema.Union([ - Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), - Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), -]) +const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) const AgentSchema = Schema.StructWithRest( Schema.Struct({ @@ -29,7 +26,7 @@ const AgentSchema = Schema.StructWithRest( }), options: Schema.optional(Schema.Record(Schema.String, Schema.Any)), color: Schema.optional(Color).annotate({ - description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)", + description: "Hex color code (e.g., #FF5733)", }), steps: Schema.optional(PositiveInt).annotate({ description: "Maximum number of agentic iterations before forcing text-only response", diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 0380c38e3a3..855ea370e2f 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -1,4 +1,4 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect, Schema } from "effect" @@ -23,6 +23,10 @@ const defaultPermissions = [ { action: "external_directory", resource: "*", effect: "ask" }, ] satisfies PermissionV2.Ruleset +test("rejects named agent color tokens", () => { + expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow() +}) + describe("ConfigAgentPlugin.Plugin", () => { it.effect("matches POSIX paths against home-relative permissions", () => Effect.gen(function* () { @@ -160,7 +164,7 @@ describe("ConfigAgentPlugin.Plugin", () => { description: "Reviews changes", mode: "subagent", hidden: true, - color: "warning", + color: "#ff6b6b", steps: 12, request: { headers: { first: "one", shared: "first" }, @@ -197,7 +201,7 @@ describe("ConfigAgentPlugin.Plugin", () => { description: "Reviews changes", mode: "subagent", hidden: true, - color: "warning", + color: "#ff6b6b", steps: 12, model: { providerID: "anthropic", id: "claude-sonnet" }, }) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 64e08b5e796..6584cc1c94c 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -738,7 +738,7 @@ describe("Config", () => { system: "Find regressions.", mode: "subagent", hidden: false, - color: "warning", + color: "#ff6b6b", steps: 12, disabled: false, permissions: [{ action: "edit", resource: "*", effect: "deny" }], @@ -824,7 +824,7 @@ describe("Config", () => { expect(reviewer?.system).toBe("Find regressions.") expect(reviewer?.mode).toBe("subagent") expect(reviewer?.hidden).toBe(false) - expect(reviewer?.color).toBe("warning") + expect(reviewer?.color).toBe("#ff6b6b") expect(reviewer?.steps).toBe(12) expect(reviewer?.disabled).toBe(false) expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }]) diff --git a/packages/docs/agents.mdx b/packages/docs/agents.mdx index a81ae6ec3c2..ca7ee80a207 100644 --- a/packages/docs/agents.mdx +++ b/packages/docs/agents.mdx @@ -89,7 +89,7 @@ becomes `system`: description: Reviews changes without modifying files mode: subagent model: anthropic/claude-sonnet-4-5#high -color: warning +color: "#ff6b6b" steps: 8 permissions: - action: edit @@ -118,7 +118,7 @@ Use the `agents` field in any [OpenCode configuration file](/config): "mode": "all", "model": "anthropic/claude-sonnet-4-5#high", "system": "Review the current changes. Report findings before any summary.", - "color": "warning", + "color": "#ff6b6b", "steps": 8, "permissions": [ { "action": "edit", "resource": "*", "effect": "deny" }, @@ -250,8 +250,7 @@ security boundary. ### `color` -Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`, or one -of `primary`, `secondary`, `accent`, `success`, `warning`, `error`, or `info`. +Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`. ### `disabled` diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 543a4181737..d430f5d21dd 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -10561,26 +10561,10 @@ "additionalProperties": false }, "Agent.Color": { - "anyOf": [ + "type": "string", + "allOf": [ { - "type": "string", - "allOf": [ - { - "pattern": "^#[0-9a-fA-F]{6}$" - } - ] - }, - { - "type": "string", - "enum": [ - "primary", - "secondary", - "accent", - "success", - "warning", - "error", - "info" - ] + "pattern": "^#[0-9a-fA-F]{6}$" } ] }, diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index 8806a9c4693..399e9e9131e 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -16,10 +16,9 @@ export type ID = typeof ID.Type export const Name = Schema.String.pipe(Schema.brand("Agent.Name")) export type Name = typeof Name.Type -export const Color = Schema.Union([ - Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), - Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), -]).annotate({ identifier: "Agent.Color" }) +export const Color = Schema.String.annotate({ identifier: "Agent.Color" }).check( + Schema.isPattern(/^#[0-9a-fA-F]{6}$/), +) export type Color = typeof Color.Type export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index c441ad88c38..c6628702690 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -20,6 +20,12 @@ import { PersistedRevert } from "../src/session-revert.js" import { optional } from "../src/schema.js" describe("contract hygiene", () => { + test("restricts agent colors to six-digit hex values", () => { + const decode = Schema.decodeUnknownSync(Agent.Color) + expect(decode("#ff6b6b")).toBe("#ff6b6b") + expect(() => decode("warning")).toThrow() + }) + test("keeps absolute costs distinct from model rates", () => { const usd = Money.USD.make(1) const rate = Money.USDPerMillionTokens.make(1) diff --git a/packages/tui/src/component/bg-pulse.tsx b/packages/tui/src/component/bg-pulse.tsx index 2112fe44209..064cc314f93 100644 --- a/packages/tui/src/component/bg-pulse.tsx +++ b/packages/tui/src/component/bg-pulse.tsx @@ -70,7 +70,7 @@ declare module "@opentui/solid" { extend({ go_upsell_art: GoUpsellArtRenderable }) export function BgPulse() { - const { theme } = useTheme() + const { themeV2, mode } = useTheme().contextual("elevated") const renderer = useRenderer() let targetFps = renderer.targetFps let maxFps = renderer.maxFps @@ -91,9 +91,9 @@ export function BgPulse() { ) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 03716cdf12a..98faea2f9d0 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -23,16 +23,6 @@ import { useRoute } from "./route" import { useData } from "./data" import { usePermission } from "./permission" -export type LocalTheme = { - secondary: RGBA - accent: RGBA - success: RGBA - warning: RGBA - primary: RGBA - error: RGBA - info: RGBA -} - export function parseModel(model: string) { const [providerID, ...rest] = model.split("/") return { @@ -60,7 +50,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const data = useData() const client = useClient() const toast = useToast() - const { theme, themeV2, mode } = useTheme() + const { themeV2, mode } = useTheme() const route = useRoute() const paths = useTuiPaths() const args = useArgs() @@ -128,12 +118,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (index === -1) return colors()[0] const agent = visibleAgents()[index] - if (agent?.color) { - const color = agent.color - if (color.startsWith("#")) return RGBA.fromHex(color) - // already validated by config, just satisfying TS here - return theme[color as keyof typeof theme] as RGBA - } + if (agent?.color) return RGBA.fromHex(agent.color) return colors()[index % colors().length] }, } diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 88f91cb50de..aff085e969e 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -71,7 +71,6 @@ type State = { type ContextName = "elevated" | "overlay" type ThemeService = { - theme: Theme themeV2: ComponentTheme contextual(context: ContextName): ThemeService readonly selected: string @@ -280,7 +279,7 @@ const themeContext = createSimpleContext({ if (supported.includes(store.mode)) return store.mode return supported[0] ?? store.mode } - const values = createMemo(() => resolveTheme(source(), mode())) + const legacySyntaxTheme = createMemo(() => resolveTheme(source(), mode())) const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName())) valuesV2() themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`) @@ -298,21 +297,13 @@ const themeContext = createSimpleContext({ }, mode), } - createEffect(() => renderer.setBackgroundColor(values().background)) + createEffect(() => renderer.setBackgroundColor(valuesV2().background.default)) - const syntax = createSyntaxStyleMemo(() => generateSyntax(values())) - - const theme = new Proxy(values(), { - get(_target, prop) { - // @ts-expect-error Properties are forwarded to the current reactive value. - return values()[prop] - }, - }) + const syntax = createSyntaxStyleMemo(() => generateSyntax(legacySyntaxTheme())) function contextual(context: ContextName) { return contextualServices[context] } const service: ThemeService = { - theme, themeV2, contextual, get selected() { diff --git a/packages/www/content/docs/(docs)/agents.mdx b/packages/www/content/docs/(docs)/agents.mdx index b1275960cb5..d5668402db4 100644 --- a/packages/www/content/docs/(docs)/agents.mdx +++ b/packages/www/content/docs/(docs)/agents.mdx @@ -89,7 +89,7 @@ becomes `system`: description: Reviews changes without modifying files mode: subagent model: anthropic/claude-sonnet-4-5#high -color: warning +color: "#ff6b6b" steps: 8 permissions: - action: edit @@ -118,7 +118,7 @@ Use the `agents` field in any [OpenCode configuration file](/docs/config): "mode": "all", "model": "anthropic/claude-sonnet-4-5#high", "system": "Review the current changes. Report findings before any summary.", - "color": "warning", + "color": "#ff6b6b", "steps": 8, "permissions": [ { "action": "edit", "resource": "*", "effect": "deny" }, @@ -250,8 +250,7 @@ security boundary. ### `color` -Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`, or one -of `primary`, `secondary`, `accent`, `success`, `warning`, `error`, or `info`. +Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`. ### `disabled` diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 543a4181737..d430f5d21dd 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -10561,26 +10561,10 @@ "additionalProperties": false }, "Agent.Color": { - "anyOf": [ + "type": "string", + "allOf": [ { - "type": "string", - "allOf": [ - { - "pattern": "^#[0-9a-fA-F]{6}$" - } - ] - }, - { - "type": "string", - "enum": [ - "primary", - "secondary", - "accent", - "success", - "warning", - "error", - "info" - ] + "pattern": "^#[0-9a-fA-F]{6}$" } ] }, From 381f6c47b46a2a4f89d37d8c698ada1b50c36057 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 22 Jul 2026 18:30:31 -0400 Subject: [PATCH 06/30] docs(tui): add generated V2 theme reference (#38396) --- .github/workflows/test.yml | 5 + packages/docs/README.md | 11 ++ packages/docs/docs.json | 1 + packages/docs/index.mdx | 2 +- packages/docs/package.json | 10 +- packages/docs/script/generate-theme-tokens.ts | 136 ++++++++++++++++++ .../docs/snippets/generated/theme-tokens.mdx | 79 ++++++++++ packages/docs/themes.mdx | 129 +++++++++++++++++ packages/tui/src/theme/v2/schema.ts | 2 +- packages/tui/test/theme/v2/resolve.test.ts | 11 +- script/generate.ts | 2 + 11 files changed, 381 insertions(+), 7 deletions(-) create mode 100644 packages/docs/script/generate-theme-tokens.ts create mode 100644 packages/docs/snippets/generated/theme-tokens.mdx create mode 100644 packages/docs/themes.mdx diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b486b68a93b..1ae28ea8744 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -97,6 +97,11 @@ jobs: working-directory: packages/client run: bun run check:generated + - name: Check generated documentation + if: runner.os == 'Linux' + working-directory: packages/docs + run: bun run check:generated + e2e: name: e2e (${{ matrix.settings.name }}) if: github.ref_name != 'v2' && github.head_ref != 'v2' diff --git a/packages/docs/README.md b/packages/docs/README.md index 17b06848e1f..1aff8cf6dc3 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -19,4 +19,15 @@ bun validate bun broken-links ``` +The V2 theme token reference is generated from +`packages/tui/src/theme/v2/schema.ts`. Regenerate it after schema changes: + +```bash +bun run generate +``` + +`bun validate` checks that the committed snippet is current. The repository's +generation workflow also refreshes it on pushes to `dev`, so Mintlify always +receives the generated MDX as part of the published docs tree. + The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site). diff --git a/packages/docs/docs.json b/packages/docs/docs.json index ff41ab4b92e..a9349f29639 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -38,6 +38,7 @@ "attachments", "compaction", "warming", + "themes", "formatters", "lsp", "references" diff --git a/packages/docs/index.mdx b/packages/docs/index.mdx index 90958539034..bbb3ccfdfe5 100644 --- a/packages/docs/index.mdx +++ b/packages/docs/index.mdx @@ -158,6 +158,6 @@ limitations and safety details. ## Customize -Make OpenCode your own by [picking a theme](https://opencode.ai/docs/themes), [customizing +Make OpenCode your own by [picking a theme](/themes), [customizing keybinds](https://opencode.ai/docs/keybinds), [configuring formatters](/formatters), [creating commands](/commands), or editing the [OpenCode config](/config). diff --git a/packages/docs/package.json b/packages/docs/package.json index 01f35998c52..b8a9ead7cae 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -3,11 +3,15 @@ "name": "@opencode-ai/docs", "private": true, "scripts": { - "dev": "bun --bun mint dev --no-open --port 3333", - "validate": "bun --bun mint validate", + "dev": "bun run generate && bun --bun mint dev --no-open --port 3333", + "generate": "bun script/generate-theme-tokens.ts", + "check:generated": "bun script/generate-theme-tokens.ts --check", + "validate": "bun run check:generated && bun --bun mint validate", "broken-links": "bun --bun mint broken-links" }, "devDependencies": { - "mint": "4.2.666" + "effect": "catalog:", + "mint": "4.2.666", + "prettier": "3.6.2" } } diff --git a/packages/docs/script/generate-theme-tokens.ts b/packages/docs/script/generate-theme-tokens.ts new file mode 100644 index 00000000000..0c441075f96 --- /dev/null +++ b/packages/docs/script/generate-theme-tokens.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env bun + +import { Schema, SchemaAST } from "effect" +import { format } from "prettier" +import { ThemeDefinition, ThemeFile } from "../../tui/src/theme/v2/schema" + +const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx" +const root = requireObject(ThemeDefinition.ast) +const hue = requireObject(requireField(root, "hue").type) +const hueNames = hue.propertySignatures.map((field) => String(field.name)) +const hueSteps = requireObject(requireField(hue, hueNames[0]).type).propertySignatures.map((field) => + String(field.name), +) +const contexts = root.propertySignatures + .map((field) => String(field.name)) + .filter((name) => name.startsWith("@context:")) +const tokens = root.propertySignatures + .filter((field) => { + const name = String(field.name) + return name !== "hue" && name !== "categorical" && !name.startsWith("@context:") + }) + .flatMap((field) => tokenPaths(field.type, String(field.name))) +const groups = Map.groupBy(tokens, (token) => + token + .split(".") + .slice(0, token.split(".").length > 2 ? 2 : 1) + .join("."), +) +const table = [...groups] + .map(([group, values]) => `| \`${group}\` | ${values.map((value) => `\`${value}\``).join("
    ")} |`) + .join("\n") +const example = { + version: 2, + light: { + hue: { + accent: "$hue.purple", + interactive: "$hue.purple", + }, + text: { + default: "$hue.neutral.900", + }, + background: { + default: "#fafafa", + }, + }, + dark: { + mergeMode: true, + text: { + default: "$hue.neutral.100", + }, + background: { + default: "#101014", + }, + }, +} satisfies ThemeFile +Schema.decodeUnknownSync(ThemeFile)(example) +const output = await format( + `{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */} + +\`\`\`json title="my-theme.json" +${JSON.stringify(example, null, 2)} +\`\`\` + +## Token reference + +This reference is generated from the Effect schema in +\`packages/tui/src/theme/v2/schema.ts\`. Changes to the runtime schema update +this section through \`bun run generate\`. + +### Hue tokens + +Every hue is a ${hueSteps.length}-step scale. Define a scale with all of these +steps, or alias it to another hue with a value such as \`$hue.blue\`. + +| | Values | +| --- | --- | +| Hues | ${hueNames.map((name) => `\`${name}\``).join(", ")} | +| Steps | ${hueSteps.map((step) => `\`${step}\``).join(", ")} | + +Reference a hue color as \`$hue..\`, for example +\`$hue.interactive.500\`. + +### Semantic tokens + +Semantic values can reference another token by prefixing its path with \`$\`, +for example \`$text.default\`. Stateful tokens inherit their \`default\` +value when a state is omitted. + +| Group | Tokens | +| --- | --- | +${table} + +### Contexts + +${contexts.map((context) => `\`${context}\``).join(" and ")} accept partial +overrides of the semantic tokens above. Components apply these contexts to +surfaces that need different contrast without changing the base theme. +`, + { parser: "mdx", printWidth: 120, semi: false }, +) + +if (process.argv.includes("--check")) { + const current = await Bun.file(target).text() + if (current === output) process.exit(0) + console.error("Generated theme token documentation is stale. Run `bun run generate` from packages/docs.") + process.exit(1) +} + +await Bun.write(target, output) + +function requireObject(ast: SchemaAST.AST): SchemaAST.Objects { + if (SchemaAST.isObjects(ast)) return ast + if (SchemaAST.isUnion(ast)) { + const object = ast.types.map(findObject).find((value) => value !== undefined) + if (object) return object + } + throw new Error(`Expected an object schema, received ${ast._tag}`) +} + +function findObject(ast: SchemaAST.AST): SchemaAST.Objects | undefined { + if (SchemaAST.isObjects(ast)) return ast + if (SchemaAST.isUnion(ast)) return ast.types.map(findObject).find((value) => value !== undefined) + if (SchemaAST.isSuspend(ast)) return findObject(ast.thunk()) +} + +function requireField(ast: SchemaAST.Objects, name: string) { + const field = ast.propertySignatures.find((field) => String(field.name) === name) + if (field) return field + throw new Error(`Theme schema field not found: ${name}`) +} + +function tokenPaths(ast: SchemaAST.AST, prefix: string): string[] { + const object = findObject(ast) + if (!object || object.propertySignatures.length === 0) return [prefix] + return object.propertySignatures.flatMap((field) => tokenPaths(field.type, `${prefix}.${String(field.name)}`)) +} diff --git a/packages/docs/snippets/generated/theme-tokens.mdx b/packages/docs/snippets/generated/theme-tokens.mdx new file mode 100644 index 00000000000..932072f6cb4 --- /dev/null +++ b/packages/docs/snippets/generated/theme-tokens.mdx @@ -0,0 +1,79 @@ +{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */} + +```json title="my-theme.json" +{ + "version": 2, + "light": { + "hue": { + "accent": "$hue.purple", + "interactive": "$hue.purple" + }, + "text": { + "default": "$hue.neutral.900" + }, + "background": { + "default": "#fafafa" + } + }, + "dark": { + "mergeMode": true, + "text": { + "default": "$hue.neutral.100" + }, + "background": { + "default": "#101014" + } + } +} +``` + +## Token reference + +This reference is generated from the Effect schema in +`packages/tui/src/theme/v2/schema.ts`. Changes to the runtime schema update +this section through `bun run generate`. + +### Hue tokens + +Every hue is a 9-step scale. Define a scale with all of these +steps, or alias it to another hue with a value such as `$hue.blue`. + +| | Values | +| ----- | -------------------------------------------------------------------------------------------------------- | +| Hues | `gray`, `red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `purple`, `accent`, `interactive`, `neutral` | +| Steps | `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900` | + +Reference a hue color as `$hue..`, for example +`$hue.interactive.500`. + +### Semantic tokens + +Semantic values can reference another token by prefixing its path with `$`, +for example `$text.default`. Stateful tokens inherit their `default` +value when a state is omitted. + +| Group | Tokens | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `text` | `text.default`
    `text.subdued` | +| `text.action` | `text.action.primary.default`
    `text.action.primary.$hovered`
    `text.action.primary.$focused`
    `text.action.primary.$pressed`
    `text.action.primary.$selected`
    `text.action.primary.$disabled`
    `text.action.destructive.default`
    `text.action.destructive.$hovered`
    `text.action.destructive.$focused`
    `text.action.destructive.$pressed`
    `text.action.destructive.$selected`
    `text.action.destructive.$disabled` | +| `text.formfield` | `text.formfield.default`
    `text.formfield.$hovered`
    `text.formfield.$focused`
    `text.formfield.$pressed`
    `text.formfield.$selected`
    `text.formfield.$disabled` | +| `text.feedback` | `text.feedback.error.default`
    `text.feedback.error.subdued`
    `text.feedback.warning.default`
    `text.feedback.warning.subdued`
    `text.feedback.success.default`
    `text.feedback.success.subdued`
    `text.feedback.info.default`
    `text.feedback.info.subdued` | +| `background` | `background.default` | +| `background.surface` | `background.surface.offset`
    `background.surface.overlay` | +| `background.action` | `background.action.primary.default`
    `background.action.primary.$hovered`
    `background.action.primary.$focused`
    `background.action.primary.$pressed`
    `background.action.primary.$selected`
    `background.action.primary.$disabled`
    `background.action.destructive.default`
    `background.action.destructive.$hovered`
    `background.action.destructive.$focused`
    `background.action.destructive.$pressed`
    `background.action.destructive.$selected`
    `background.action.destructive.$disabled` | +| `background.formfield` | `background.formfield.default`
    `background.formfield.$hovered`
    `background.formfield.$focused`
    `background.formfield.$pressed`
    `background.formfield.$selected`
    `background.formfield.$disabled` | +| `background.feedback` | `background.feedback.error.default`
    `background.feedback.warning.default`
    `background.feedback.success.default`
    `background.feedback.info.default` | +| `border` | `border.default` | +| `scrollbar` | `scrollbar.default` | +| `diff.text` | `diff.text.added`
    `diff.text.removed`
    `diff.text.context`
    `diff.text.hunkHeader` | +| `diff.background` | `diff.background.added`
    `diff.background.removed`
    `diff.background.context` | +| `diff.highlight` | `diff.highlight.added`
    `diff.highlight.removed` | +| `diff.lineNumber` | `diff.lineNumber.text`
    `diff.lineNumber.background.added`
    `diff.lineNumber.background.removed` | +| `syntax` | `syntax.comment`
    `syntax.keyword`
    `syntax.function`
    `syntax.variable`
    `syntax.string`
    `syntax.number`
    `syntax.type`
    `syntax.operator`
    `syntax.punctuation` | +| `markdown` | `markdown.text`
    `markdown.heading`
    `markdown.link`
    `markdown.linkText`
    `markdown.code`
    `markdown.blockQuote`
    `markdown.emphasis`
    `markdown.strong`
    `markdown.horizontalRule`
    `markdown.listItem`
    `markdown.listEnumeration`
    `markdown.image`
    `markdown.imageText`
    `markdown.codeBlock` | + +### Contexts + +`@context:elevated` and `@context:overlay` accept partial +overrides of the semantic tokens above. Components apply these contexts to +surfaces that need different contrast without changing the base theme. diff --git a/packages/docs/themes.mdx b/packages/docs/themes.mdx new file mode 100644 index 00000000000..49f41768f60 --- /dev/null +++ b/packages/docs/themes.mdx @@ -0,0 +1,129 @@ +--- +title: "Themes" +description: "Choose a built-in TUI theme or create a custom color scheme." +--- + +import ThemeTokens from "/snippets/generated/theme-tokens.mdx" + +OpenCode includes built-in light and dark themes and can load custom themes +from your global configuration or a project directory. The default theme is +`opencode`. + +## Choose a theme + +In the full-screen TUI, run: + +```text +/themes +``` + +You can also open the picker with `ctrl+x`, then `t`, using the +default keybindings. + +Use `/settings` to change both the theme and its color mode. OpenCode supports +three modes: + +| Mode | Behavior | +| -------- | -------------------------------------------------------- | +| `system` | Follow the terminal's detected light or dark appearance. | +| `dark` | Always use the theme's dark colors. | +| `light` | Always use the theme's light colors. | + +Your selection is stored in `~/.config/opencode/cli.json`, or the equivalent +path under `$XDG_CONFIG_HOME`: + +```json title="cli.json" +{ + "theme": { + "name": "tokyonight", + "mode": "system" + } +} +``` + + + Theme selection applies to the full-screen TUI. Direct interactive runs use colors derived from the terminal palette + and honor only the color mode. + + +## Built-in themes + +OpenCode currently includes: + +| | | | +| ------------ | ------------------- | ---------------------- | +| `aura` | `ayu` | `carbonfox` | +| `catppuccin` | `catppuccin-frappe` | `catppuccin-macchiato` | +| `cobalt2` | `cursor` | `dracula` | +| `everforest` | `flexoki` | `github` | +| `gruvbox` | `kanagawa` | `lucent-orng` | +| `material` | `matrix` | `mercury` | +| `monokai` | `nightowl` | `nord` | +| `one-dark` | `opencode` | `orng` | +| `osaka-jade` | `palenight` | `rosepine` | +| `solarized` | `synthwave84` | `tokyonight` | +| `vercel` | `vesper` | `zenburn` | + +When OpenCode can read your terminal palette, the picker also includes +`system`. The `system` theme generates its colors from your terminal's +foreground, background, and ANSI palette. + +## Custom themes + +Create a JSON file in either of these locations: + +```text +~/.config/opencode/themes/my-theme.json +.opencode/themes/my-theme.json +``` + +OpenCode checks the global theme directory first, followed by every +`.opencode/themes` directory from the filesystem root down to the current +directory. A more local file with the same filename overrides an earlier one. +The filename becomes the theme name, so `my-theme.json` appears as `my-theme`. + +Custom theme files must be strict JSON. Comments and trailing commas are not +supported. + +### Format + +V2 themes organize colors into hue scales and semantic tokens. Set `version` +to `2` and define at least one of `light` or `dark`: + + + Native V2 custom theme files are not loaded directly by the current beta. Existing custom files use the V1 format and + are migrated to these tokens at runtime. This reference tracks the native V2 schema while direct file loading is + completed. + + +By default, a theme inherits OpenCode's complete theme, so you only need to +define overrides. Set `mergeMode` to `true` to inherit one mode from the other +before applying that mode's overrides. Set `standalone` to `true` only when you +intend to supply a complete independent theme. + +Each token accepts: + +- A hex color such as `"#5c9cf5"` +- `"transparent"` to use the terminal default +- A hue reference such as `"$hue.blue.500"` +- Another semantic token reference such as `"$text.default"` + +Syntax and markdown tokens accept hex colors and hue references. Other +semantic tokens can reference any semantic token. + + + +If you add or edit a custom theme while OpenCode is running, restart the TUI to +reload it. + +## Terminal colors + +Themes display most accurately in a terminal with truecolor support. Check +your terminal with: + +```bash +echo $COLORTERM +``` + +Most modern terminals report `truecolor` or `24bit`. Without truecolor, +OpenCode approximates theme colors using the available terminal palette. diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/tui/src/theme/v2/schema.ts index 076bdfc6353..76a33f7a099 100644 --- a/packages/tui/src/theme/v2/schema.ts +++ b/packages/tui/src/theme/v2/schema.ts @@ -243,7 +243,7 @@ const MergeModeDefinition = Schema.Struct({ "@context:overlay": Schema.optional(ThemeTokensDefinition), }) export type MergeModeDefinition = Schema.Schema.Type -export const ModeDefinition = Schema.Union([FileThemeDefinition, MergeModeDefinition]) +export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition]) export type ModeDefinition = Schema.Schema.Type const FileMetadata = { diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index 237b4c79fc0..e91a79d2b20 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -192,8 +192,15 @@ test("standalone themes skip OpenCode defaults and use the red core fallback", ( }) test("uses defaults for the selected mode when it merges the other mode", () => { - const theme = resolveThemeFile({ version: 2, light: { hue: light.hue }, dark: { mergeMode: true } }, "dark") - expect(theme.background.default.toInts()).toEqual(resolveTheme(dark).background.default.toInts()) + const theme = resolveThemeFile( + { + version: 2, + light: { hue: light.hue, background: { default: "#123456" } }, + dark: { mergeMode: true }, + }, + "dark", + ) + expect(theme.background.default.toInts()).toEqual([18, 52, 86, 255]) }) test("resolves matched action variants and states", () => { diff --git a/script/generate.ts b/script/generate.ts index 8fc251d89d4..dbf38f8a3c2 100755 --- a/script/generate.ts +++ b/script/generate.ts @@ -6,4 +6,6 @@ await $`bun ./packages/sdk/js/script/build.ts` await $`bun dev generate > ../sdk/openapi.json`.cwd("packages/opencode") +await $`bun run generate`.cwd("packages/docs") + await $`./script/format.ts` From a817fe5e6ce078918a1488b390b4b08a1852ee14 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 22 Jul 2026 19:20:28 -0400 Subject: [PATCH 07/30] fix(schema): loosen agent color response --- packages/client/src/promise/generated/types.ts | 4 +++- packages/schema/src/agent.ts | 4 +--- packages/schema/test/agent.test.ts | 10 ++++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 packages/schema/test/agent.test.ts diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 966ec22cc03..a08c7b32468 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -8,6 +8,8 @@ export type ModelRef = { id: string; providerID: string; variant?: string } export type ProviderSettings = { [x: string]: JsonValue } +export type AgentColor = string + export type PermissionV2Effect = "allow" | "deny" | "ask" export type PluginInfo = { id: string } @@ -2003,7 +2005,7 @@ export type AgentInfo = { description?: string mode: "subagent" | "primary" | "all" hidden: boolean - color?: string + color?: AgentColor steps?: number permissions: PermissionV2Ruleset } diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index 399e9e9131e..8001a2967ee 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -16,9 +16,7 @@ export type ID = typeof ID.Type export const Name = Schema.String.pipe(Schema.brand("Agent.Name")) export type Name = typeof Name.Type -export const Color = Schema.String.annotate({ identifier: "Agent.Color" }).check( - Schema.isPattern(/^#[0-9a-fA-F]{6}$/), -) +export const Color = Schema.String.annotate({ identifier: "Agent.Color" }) export type Color = typeof Color.Type export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/test/agent.test.ts b/packages/schema/test/agent.test.ts new file mode 100644 index 00000000000..30535ebaecf --- /dev/null +++ b/packages/schema/test/agent.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { Agent } from "../src/agent.js" + +test("Agent.Color preserves configured colors at the public boundary", () => { + const encode = Schema.encodeSync(Agent.Color) + + expect(encode("info")).toBe("info") + expect(encode("custom-color")).toBe("custom-color") +}) From d86f732df325a4d5933b47a34c5759cdad784117 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 22 Jul 2026 21:33:25 -0400 Subject: [PATCH 08/30] refactor(tui): generate syntax from V2 theme (#38397) --- packages/tui/src/context/theme.tsx | 6 +- packages/tui/src/theme/v2/syntax.ts | 93 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 packages/tui/src/theme/v2/syntax.ts diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index aff085e969e..16ffdeaccbe 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -4,10 +4,8 @@ import { DEFAULT_THEMES, addTheme, allThemes, - generateSyntax, hasTheme, isTheme, - resolveTheme, selectedForeground, setCustomThemes, setSystemTheme, @@ -16,6 +14,7 @@ import { type Theme, type ThemeJson, } from "../theme" +import { generateSyntax } from "../theme/v2/syntax" import { generateSystem, terminalMode } from "../theme/system" import { discoverThemes, themeDirectories } from "../theme/discovery" import { createComponentTheme, type ComponentTheme } from "../theme/v2/component" @@ -279,7 +278,6 @@ const themeContext = createSimpleContext({ if (supported.includes(store.mode)) return store.mode return supported[0] ?? store.mode } - const legacySyntaxTheme = createMemo(() => resolveTheme(source(), mode())) const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName())) valuesV2() themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`) @@ -299,7 +297,7 @@ const themeContext = createSimpleContext({ createEffect(() => renderer.setBackgroundColor(valuesV2().background.default)) - const syntax = createSyntaxStyleMemo(() => generateSyntax(legacySyntaxTheme())) + const syntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode())) function contextual(context: ContextName) { return contextualServices[context] } diff --git a/packages/tui/src/theme/v2/syntax.ts b/packages/tui/src/theme/v2/syntax.ts new file mode 100644 index 00000000000..4a8917f089d --- /dev/null +++ b/packages/tui/src/theme/v2/syntax.ts @@ -0,0 +1,93 @@ +import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core" +import type { Mode, ResolvedThemeView } from "./index" + +export function generateSyntax(theme: ResolvedThemeView, mode: Mode) { + const step = mode === "light" ? 800 : 200 + const syntax = theme.syntax + const markdown = theme.markdown + const feedback = theme.text.feedback + + return SyntaxStyle.fromTheme([ + rule(["default"], theme.text.default), + rule(["prompt"], theme.hue.accent[step]), + rule(["extmark.file"], feedback.warning.default, { bold: true }), + rule(["extmark.agent"], theme.categorical[0][step], { bold: true }), + // V1 migration preserves its selected/inverse foreground in this action state. + rule(["extmark.paste"], theme.text.action.primary.focused, { + background: feedback.warning.default, + bold: true, + }), + rule(["comment", "comment.documentation"], syntax.comment, { italic: true }), + rule(["string", "symbol", "character.special", "character"], syntax.string), + rule(["number", "boolean", "constant", "float"], syntax.number), + rule(["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], syntax.keyword, { + italic: true, + }), + rule(["keyword.type"], syntax.type, { bold: true, italic: true }), + rule(["keyword.function", "function.method"], syntax.function), + rule(["keyword"], syntax.keyword, { italic: true }), + rule(["keyword.import", "string.escape", "string.regexp", "tag.attribute", "keyword.export"], syntax.keyword), + rule(["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary"], syntax.operator), + rule( + ["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"], + syntax.variable, + ), + rule(["variable.member", "function", "constructor"], syntax.function), + rule(["type", "module", "class", "namespace"], syntax.type), + rule(["type.definition"], syntax.type, { bold: true }), + rule(["punctuation", "punctuation.bracket"], syntax.punctuation), + rule( + ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super"], + feedback.error.default, + ), + rule(["keyword.directive", "keyword.modifier", "keyword.exception"], syntax.keyword, { italic: true }), + rule(["punctuation.special", "tag.delimiter"], syntax.operator), + rule( + [ + "markup.heading", + "markup.heading.2", + "markup.heading.3", + "markup.heading.4", + "markup.heading.5", + "markup.heading.6", + ], + markdown.heading, + { bold: true }, + ), + rule(["markup.heading.1"], markdown.heading, { bold: true, underline: true }), + rule(["markup.bold", "markup.strong"], markdown.strong, { bold: true }), + rule(["markup.italic"], markdown.emphasis, { italic: true }), + rule(["markup.list"], markdown.listItem), + rule(["markup.quote"], markdown.blockQuote, { italic: true }), + rule(["markup.raw", "markup.raw.block"], markdown.code), + rule(["markup.raw.inline"], markdown.code, { background: theme.background.default }), + rule(["markup.link", "markup.link.url", "string.special", "string.special.url"], markdown.link, { + underline: true, + }), + rule(["markup.link.label"], markdown.linkText, { underline: true }), + rule(["label"], markdown.linkText), + rule(["spell", "nospell"], theme.text.default), + rule(["markup.underline"], theme.text.default, { underline: true }), + rule(["comment.error"], feedback.error.default, { italic: true, bold: true }), + rule(["comment.warning"], feedback.warning.default, { italic: true, bold: true }), + rule(["comment.todo", "comment.note"], feedback.info.default, { italic: true, bold: true }), + rule(["attribute", "annotation"], feedback.warning.default), + rule(["tag"], feedback.error.default), + rule(["markup.strikethrough", "markup.list.unchecked", "debug"], theme.text.subdued), + rule(["markup.list.checked"], feedback.success.default), + rule(["diff.plus"], theme.diff.text.added, { background: theme.diff.background.added }), + rule(["diff.minus"], theme.diff.text.removed, { background: theme.diff.background.removed }), + rule(["diff.delta"], theme.diff.text.context, { background: theme.diff.background.context }), + rule(["error"], feedback.error.default, { bold: true }), + rule(["warning"], feedback.warning.default, { bold: true }), + rule(["info"], feedback.info.default), + ]) +} + +function rule( + scope: string[], + foreground: RGBA, + style: Omit = {}, +): ThemeTokenStyle { + return { scope, style: { foreground, ...style } } +} From 48bcbd09efe4eda454ed862909956f60e94f064f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:49:08 -0500 Subject: [PATCH 09/30] fix(ai): handle incomplete responses without reasons (#38374) --- packages/ai/src/protocols/openai-responses.ts | 5 ++-- .../ai/test/provider/openai-responses.test.ts | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index 87ca8c75e1a..2973acf0005 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -253,7 +253,7 @@ const OpenAIResponsesEvent = Schema.Struct({ Schema.Struct({ id: Schema.optional(Schema.String), service_tier: optionalNull(Schema.String), - incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })), + incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })), usage: optionalNull(OpenAIResponsesUsage), error: optionalNull(OpenAIResponsesErrorPayload), }), @@ -602,7 +602,8 @@ const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => { const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => { const reason = event.response?.incomplete_details?.reason - if (reason === undefined || reason === null) return hasFunctionCall ? "tool-calls" : "stop" + if (reason === undefined || reason === null) + return hasFunctionCall ? "tool-calls" : event.type === "response.incomplete" ? "unknown" : "stop" if (reason === "max_output_tokens") return "length" if (reason === "content_filter") return "content-filter" return hasFunctionCall ? "tool-calls" : "unknown" diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 11466a6896f..ffbd3294341 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -870,6 +870,32 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("maps incomplete response reasons", () => + Effect.gen(function* () { + const generate = (incompleteDetails: object) => + LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "response.incomplete", + response: { id: "resp_incomplete", incomplete_details: incompleteDetails }, + }), + ), + ), + ) + + const length = yield* generate({ reason: "max_output_tokens" }) + const contentFilter = yield* generate({ reason: "content_filter" }) + const unknown = yield* generate({}) + + expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([ + "length", + "content-filter", + "unknown", + ]) + }), + ) + // OpenAI's documented stream orders output text within one message item; no // provider-valid same-kind overlap is evidenced, so done boundaries close it. it.effect("closes sequential output messages before starting the next", () => From 203b9f59b73b695664d08834fba98fb630ca3421 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 22 Jul 2026 22:26:08 -0400 Subject: [PATCH 10/30] fix(core): load dynamic models for generation (#38401) --- packages/core/src/generate.ts | 80 ++-- packages/core/src/location-services.ts | 2 + packages/core/src/model-resolver.ts | 344 ++++++++++++++++++ .../core/src/plugin/provider/openai-codex.ts | 2 +- packages/core/src/session/runner/model.ts | 344 ++---------------- packages/core/test/generate.test.ts | 111 ++++++ ...r-model.test.ts => model-resolver.test.ts} | 130 ++----- packages/core/test/session-compact.test.ts | 17 +- packages/core/test/session-generate.test.ts | 17 +- .../core/test/session-runner-recorded.test.ts | 17 +- packages/core/test/session-runner.test.ts | 21 +- packages/core/test/tool-search.test.ts | 4 +- 12 files changed, 586 insertions(+), 503 deletions(-) create mode 100644 packages/core/src/model-resolver.ts create mode 100644 packages/core/test/generate.test.ts rename packages/core/test/{session-runner-model.test.ts => model-resolver.test.ts} (80%) diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index f4a79e9aa26..432827bce0b 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -2,12 +2,10 @@ export * as Generate from "./generate" import { LLM, LLMClient, LLMError } from "@opencode-ai/ai" import { Context, Effect, Layer, Schema } from "effect" -import { Catalog } from "./catalog" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { llmClient } from "./effect/app-node-platform" -import { Integration } from "./integration" +import { ModelResolver } from "./model-resolver" import { ModelV2 } from "./model" -import { SessionRunnerModel } from "./session/runner/model" export interface TextInput { readonly prompt: string @@ -19,10 +17,10 @@ export class ModelSelectionError extends Schema.TaggedErrorClass()( - "Generate.UnavailableError", - { message: Schema.String, service: Schema.optional(Schema.String) }, -) {} +export class UnavailableError extends Schema.TaggedErrorClass()("Generate.UnavailableError", { + message: Schema.String, + service: Schema.optional(Schema.String), +}) {} export type Error = ModelSelectionError | UnavailableError @@ -35,56 +33,34 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service const llm = yield* LLMClient.Service - - const selectModel = Effect.fn("Generate.selectModel")(function* (requested?: ModelV2.Ref) { - const selected = requested - ? yield* catalog.model.get(requested.providerID, requested.id) - : yield* catalog.model.default().pipe( - Effect.flatMap((model) => - model && SessionRunnerModel.supported(model) - ? Effect.succeed(model) - : Effect.map(catalog.model.available(), (models) => models.find(SessionRunnerModel.supported)), - ), - ) - if (!selected) - return yield* new ModelSelectionError({ - message: requested - ? `Model unavailable: ${requested.providerID}/${requested.id}` - : "No model specified and no supported model is available", - }) - return yield* SessionRunnerModel.withVariant(selected, requested?.variant).pipe( - Effect.mapError( - () => - new ModelSelectionError({ - message: `Variant unavailable for ${selected.providerID}/${selected.id}: ${requested?.variant}`, - }), - ), - ) - }) + const resolver = yield* ModelResolver.Service const runText = Effect.fn("Generate.text")(function* (input: TextInput) { - const selected = yield* selectModel(input.model) - const provider = yield* catalog.provider.get(selected.providerID) - const connection = yield* integrations.connection.active( - provider?.integrationID ?? Integration.ID.make(selected.providerID), + const resolved = yield* resolver.resolve(input.model).pipe( + Effect.catchTags({ + "SessionRunnerModel.VariantUnavailableError": (error) => + input.model + ? new ModelSelectionError({ message: error.message }) + : new UnavailableError({ message: error.message, service: error.providerID }), + "SessionRunnerModel.UnsupportedPackageError": (error) => + input.model + ? new ModelSelectionError({ message: error.message }) + : new UnavailableError({ message: error.message, service: error.providerID }), + }), ) - const credential = connection ? yield* integrations.connection.resolve(connection) : undefined - const model = yield* SessionRunnerModel.fromCatalogModel(selected, credential).pipe( - Effect.mapError((error) => - input.model - ? new ModelSelectionError({ message: error.message }) - : new UnavailableError({ message: error.message, service: selected.providerID }), - ), - ) - const response = yield* llm.generate(LLM.request({ model, prompt: input.prompt })).pipe( + if (!resolved) + return yield* new ModelSelectionError({ + message: input.model + ? `Model unavailable: ${input.model.providerID}/${input.model.id}` + : "No model specified and no supported model is available", + }) + const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe( Effect.mapError( (error: LLMError) => new UnavailableError({ message: error.message, - service: selected.providerID, + service: resolved.ref.providerID, }), ), ) @@ -106,4 +82,8 @@ export const layer = Layer.effect( }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node, llmClient] }) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ModelResolver.node, llmClient], +}) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7be9fefe3fb..adf1afe62be 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -20,6 +20,7 @@ import { Integration } from "./integration" import { Location } from "./location" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" +import { ModelResolver } from "./model-resolver" import { MCP } from "./mcp/index" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" @@ -58,6 +59,7 @@ const locationServiceNodes = [ Reference.node, Integration.node, Catalog.node, + ModelResolver.node, AISDK.node, PluginV2.node, PluginSupervisor.node, diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts new file mode 100644 index 00000000000..44faea8b12d --- /dev/null +++ b/packages/core/src/model-resolver.ts @@ -0,0 +1,344 @@ +export * as ModelResolver from "./model-resolver" + +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { Model } from "@opencode-ai/ai" +// ast-grep-ignore: no-star-import +import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" +// ast-grep-ignore: no-star-import +import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat" +// ast-grep-ignore: no-star-import +import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses" +import { Auth, type AnyRoute } from "@opencode-ai/ai/route" +import { Context, Effect, Layer, Schema } from "effect" +import { produce } from "immer" +import { AISDK } from "./aisdk" +import { Catalog } from "./catalog" +import { Credential } from "./credential" +import { Integration } from "./integration" +import { ModelV2 } from "./model" +import { Npm } from "@opencode-ai/util/npm" +import { OpenAICodex } from "./plugin/provider/openai-codex" +import { ProviderV2 } from "./provider" + +export class VariantUnavailableError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.VariantUnavailableError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + variant: ModelV2.VariantID, + }, +) { + override get message() { + return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}` + } +} + +export class UnsupportedPackageError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.UnsupportedPackageError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + package: Schema.String, + }, +) { + override get message() { + return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}` + } +} + +export type Error = VariantUnavailableError | UnsupportedPackageError | Integration.AuthorizationError + +export interface Resolved { + /** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */ + readonly model: Model + /** Selected catalog identity. Durable records and displays must use this, never the API model id. */ + readonly ref: ModelV2.Ref + /** Catalog capabilities used to shape requests before provider lowering. */ + readonly capabilities: ModelV2.Capabilities + /** Catalog pricing in dollars per million tokens. */ + readonly cost: ModelV2.Info["cost"] +} + +export interface Interface { + readonly resolve: (requested?: ModelV2.Ref) => Effect.Effect + readonly resolveModel: (model: ModelV2.Info, variant?: ModelV2.VariantID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/ModelResolver") {} + +const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { + if (credential?.type === "key") return Auth.value(credential.key) + if (credential?.type === "oauth") return Auth.value(credential.access) + const value = model.settings?.apiKey + if (typeof value === "string") return Auth.value(value) + return undefined +} + +const withDefaults = (model: ModelV2.Info, route: AnyRoute) => + route.with({ + provider: model.providerID, + endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined, + headers: providerHeaders(model), + providerOptions: providerOptions(model), + http: model.body === undefined ? undefined : { body: model.body }, + limits: { context: model.limit.context, output: model.limit.output }, + }) + +const providerHeaders = (model: ModelV2.Info) => { + const packageName = ProviderV2.packageName(model.package) + const generated = new Map() + if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string") + generated.set("OpenAI-Organization", model.settings.organization) + if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string") + generated.set("OpenAI-Project", model.settings.project) + if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string") + generated.set("Authorization", `Bearer ${model.settings.authToken}`) + return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers) +} + +const providerOptions = ( + model: ModelV2.Info, +): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => { + if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined + const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings + if (Object.keys(settings).length === 0) return undefined + const packageName = ProviderV2.packageName(model.package) + if (packageName === "@ai-sdk/openai") return { openai: settings } + if (packageName === "@ai-sdk/anthropic") return { anthropic: settings } + if (packageName === "@ai-sdk/openai-compatible") return { openai: settings } + return undefined +} + +export const withVariant = ( + model: ModelV2.Info, + variantID: ModelV2.VariantID | undefined, +): Effect.Effect => { + const id = variantID === "default" ? undefined : variantID + const variant = model.variants?.find((item) => item.id === id) + if (!variant && variantID !== undefined && variantID !== "default") + return Effect.fail( + new VariantUnavailableError({ + providerID: model.providerID, + modelID: model.id, + variant: variantID, + }), + ) + return Effect.succeed( + variant + ? produce(model, (draft) => { + draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings) + draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers) + draft.body = ProviderV2.mergeOverlay(draft.body, variant.body) + }) + : model, + ) +} + +export interface Dependencies { + readonly loadPackage?: (specifier: string) => Effect.Effect + readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect +} + +export const fromCatalogModel = ( + model: ModelV2.Info, + credential?: Credential.Value, + dependencies?: Dependencies, +): Effect.Effect => { + const resolved = produce(model, (draft) => { + if (draft.settings?.apiKey === "") delete draft.settings.apiKey + if (credential?.type === "key" && credential.metadata !== undefined) + draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata) + }) + const packageName = ProviderV2.packageName(resolved.package) + const key = apiKey(resolved, credential) + + if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) { + return Effect.succeed(codexModel(resolved, credential, key)) + } + + if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") { + if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key)) + return Effect.succeed( + withDefaults(resolved, OpenAIResponses.route) + .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) + .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), + ) + } + if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") { + return Effect.succeed( + withDefaults(resolved, AnthropicMessages.route) + .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) + .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), + ) + } + if ( + ProviderV2.isAISDK(resolved.package) && + packageName === "@ai-sdk/openai-compatible" && + typeof resolved.settings?.baseURL === "string" + ) { + return Effect.succeed( + withDefaults(resolved, OpenAICompatibleChat.route) + .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) + .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), + ) + } + if (ProviderV2.isAISDK(resolved.package)) { + if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved)) + const runtime = produce(resolved, (draft) => { + draft.settings = ProviderV2.mergeOverlay(draft.settings, { + ...(credential?.type === "key" ? { apiKey: credential.key } : {}), + ...(credential?.type === "oauth" ? { apiKey: credential.access } : {}), + ...credential?.metadata, + }) + }) + return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved))) + } + if (!resolved.package) return Effect.fail(unsupported(resolved)) + + const specifier = resolved.package + return Effect.gen(function* () { + const module = yield* (dependencies?.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe( + Effect.mapError(() => unsupported(resolved)), + ) + const configured = { ...resolved.settings, ...credential?.metadata } + const settings = { + ...(credential ? withoutNativeAuthSettings(configured) : configured), + ...nativeCredentialSettings(specifier, credential), + headers: resolved.headers, + body: resolved.body, + limits: { context: resolved.limit.context, output: resolved.limit.output }, + } + return yield* Effect.try({ + try: () => { + const runtime = module.model(resolved.modelID ?? resolved.id, settings) + return Model.update(runtime, { + provider: resolved.providerID, + compatibility: resolved.compatibility + ? Object.assign({}, runtime.compatibility, resolved.compatibility) + : runtime.compatibility, + }) + }, + catch: () => unsupported(resolved), + }) + }) +} + +const isNativeOpenAI = (packageName: string | undefined) => + packageName === "@opencode-ai/ai/providers/openai" || + packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true + +const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => { + if (!credential) return {} + if (credential.type === "key") return { apiKey: credential.key } + if ( + specifier === "@opencode-ai/ai/providers/anthropic" || + specifier === "@opencode-ai/ai/providers/anthropic-compatible" + ) + return { authToken: credential.access } + if ( + specifier === "@opencode-ai/ai/providers/google-vertex" || + specifier.startsWith("@opencode-ai/ai/providers/google-vertex/") + ) + return { accessToken: credential.access } + return { apiKey: credential.access } +} + +const withoutNativeAuthSettings = (settings: Record) => { + const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings + return rest +} + +const codexModel = ( + model: ModelV2.Info, + credential: Credential.Value | undefined, + key: ReturnType | undefined, +) => { + const account = OpenAICodex.accountID(credential) + return withDefaults(model, OpenAIResponses.route) + .with({ + endpoint: { baseURL: OpenAICodex.baseURL }, + auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen( + account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }), + ), + }) + .model({ id: model.modelID ?? model.id, compatibility: model.compatibility }) +} + +const unsupported = (model: ModelV2.Info) => + new UnsupportedPackageError({ + providerID: model.providerID, + modelID: model.id, + package: model.package ?? "unknown", + }) + +export const resolveModel = ( + model: ModelV2.Info, + variant: ModelV2.VariantID | undefined, + credential?: Credential.Value, + dependencies?: Dependencies, +) => withVariant(model, variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies))) + +export const supported = (model: ModelV2.Info) => Boolean(model.package) + +/** Resolves catalog selections into runtime models for the current Location. */ +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service + const npm = yield* Npm.Service + const aisdk = yield* AISDK.Service + const load = Effect.fn("ModelResolver.resolveModel")(function* ( + selected: ModelV2.Info, + variant?: ModelV2.VariantID, + ) { + const provider = yield* catalog.provider.get(selected.providerID) + const connection = yield* integrations.connection.active( + provider?.integrationID ?? Integration.ID.make(selected.providerID), + ) + const model = yield* resolveModel( + selected, + variant, + connection ? yield* integrations.connection.resolve(connection) : undefined, + { + loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm), + loadAISDK: (model) => aisdk.model(model), + }, + ) + return { + model, + ref: ModelV2.Ref.make({ + id: selected.id, + providerID: selected.providerID, + ...(variant === undefined ? {} : { variant }), + }), + capabilities: selected.capabilities, + cost: selected.cost, + } + }) + return Service.of({ + resolve: Effect.fn("ModelResolver.resolve")(function* (requested) { + const selected = requested + ? yield* catalog.model.get(requested.providerID, requested.id) + : yield* catalog.model + .default() + .pipe( + Effect.flatMap((model) => + model && supported(model) + ? Effect.succeed(model) + : Effect.map(catalog.model.available(), (models) => models.find(supported)), + ), + ) + if (!selected) return undefined + return yield* load(selected, requested?.variant) + }), + resolveModel: load, + }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Catalog.node, Integration.node, Npm.node, AISDK.node], +}) diff --git a/packages/core/src/plugin/provider/openai-codex.ts b/packages/core/src/plugin/provider/openai-codex.ts index 8d4389d969e..eb734b29f3d 100644 --- a/packages/core/src/plugin/provider/openai-codex.ts +++ b/packages/core/src/plugin/provider/openai-codex.ts @@ -1,7 +1,7 @@ export * as OpenAICodex from "./openai-codex" // TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so -// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering +// Codex routing lives in ModelResolver and catalog filtering. // in OpenAIPlugin, sharing this module. Once the native provider packages land // (#33689/#33925/#34462) this should collapse into the native OpenAI provider. // The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 6013ce7b740..d0902d7d674 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -2,30 +2,16 @@ export * as SessionRunnerModel from "./model" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Model } from "@opencode-ai/ai" -// ast-grep-ignore: no-star-import -import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages" -// ast-grep-ignore: no-star-import -import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat" -// ast-grep-ignore: no-star-import -import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses" -import { Auth, type AnyRoute } from "@opencode-ai/ai/route" import { Context, Effect, Layer, Schema } from "effect" -import { produce } from "immer" -import { AISDK } from "../../aisdk" import { Catalog } from "../../catalog" -import { Credential } from "../../credential" -import { Integration } from "../../integration" +import { ModelResolver } from "../../model-resolver" import { ModelV2 } from "../../model" -import { Npm } from "@opencode-ai/util/npm" -import { OpenAICodex } from "../../plugin/provider/openai-codex" import { ProviderV2 } from "../../provider" import { SessionSchema } from "../schema" export class ModelNotSelectedError extends Schema.TaggedErrorClass()( "SessionRunnerModel.ModelNotSelectedError", - { - sessionID: SessionSchema.ID, - }, + { sessionID: SessionSchema.ID }, ) { override get message() { return `No model is available for session ${this.sessionID}` @@ -34,59 +20,19 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass()( "SessionRunnerModel.ModelUnavailableError", - { - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - }, + { providerID: ProviderV2.ID, modelID: ModelV2.ID }, ) { override get message() { return `Model unavailable: ${this.providerID}/${this.modelID}` } } +export const VariantUnavailableError = ModelResolver.VariantUnavailableError +export type VariantUnavailableError = ModelResolver.VariantUnavailableError +export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError +export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError -export class VariantUnavailableError extends Schema.TaggedErrorClass()( - "SessionRunnerModel.VariantUnavailableError", - { - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - variant: ModelV2.VariantID, - }, -) { - override get message() { - return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}` - } -} - -export class UnsupportedPackageError extends Schema.TaggedErrorClass()( - "SessionRunnerModel.UnsupportedPackageError", - { - providerID: ProviderV2.ID, - modelID: ModelV2.ID, - package: Schema.String, - }, -) { - override get message() { - return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}` - } -} - -export type Error = - | ModelNotSelectedError - | ModelUnavailableError - | VariantUnavailableError - | UnsupportedPackageError - | Integration.AuthorizationError - -export interface Resolved { - /** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */ - readonly model: Model - /** Selected catalog identity. Durable records and displays must use this, never the API model id. */ - readonly ref: ModelV2.Ref - /** Catalog capabilities used to shape requests before provider lowering. */ - readonly capabilities: ModelV2.Capabilities - /** Catalog pricing in dollars per million tokens. */ - readonly cost: ModelV2.Info["cost"] -} +export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error +export type Resolved = ModelResolver.Resolved export interface Interface { readonly resolve: (session: SessionSchema.Info) => Effect.Effect @@ -94,9 +40,6 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/SessionRunnerModel") {} -/** Test or embedding seam for supplying a model resolver directly. */ -export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) - /** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */ export const resolved = ( model: Model, @@ -116,276 +59,31 @@ export const resolved = ( cost: options.cost, }) -const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { - if (credential?.type === "key") return Auth.value(credential.key) - if (credential?.type === "oauth") return Auth.value(credential.access) - const value = model.settings?.apiKey - if (typeof value === "string") return Auth.value(value) -} - -const withDefaults = (model: ModelV2.Info, route: AnyRoute) => - route.with({ - provider: model.providerID, - endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined, - headers: providerHeaders(model), - providerOptions: providerOptions(model), - http: model.body === undefined ? undefined : { body: model.body }, - limits: { context: model.limit.context, output: model.limit.output }, - }) - -const providerHeaders = (model: ModelV2.Info) => { - const packageName = ProviderV2.packageName(model.package) - const generated = new Map() - if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string") - generated.set("OpenAI-Organization", model.settings.organization) - if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string") - generated.set("OpenAI-Project", model.settings.project) - if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string") - generated.set("Authorization", `Bearer ${model.settings.authToken}`) - return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers) -} - -const providerOptions = ( - model: ModelV2.Info, -): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => { - if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined - const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings - if (Object.keys(settings).length === 0) return undefined - const packageName = ProviderV2.packageName(model.package) - if (packageName === "@ai-sdk/openai") return { openai: settings } - if (packageName === "@ai-sdk/anthropic") return { anthropic: settings } - if (packageName === "@ai-sdk/openai-compatible") return { openai: settings } -} - -export const withVariant = ( - model: ModelV2.Info, - variantID: ModelV2.VariantID | undefined, -): Effect.Effect => { - const id = variantID === "default" ? undefined : variantID - const variant = model.variants?.find((item) => item.id === id) - if (!variant && variantID !== undefined && variantID !== "default") - return Effect.fail( - new VariantUnavailableError({ - providerID: model.providerID, - modelID: model.id, - variant: variantID, - }), - ) - return Effect.succeed( - variant - ? produce(model, (draft) => { - draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings) - draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers) - draft.body = ProviderV2.mergeOverlay(draft.body, variant.body) - }) - : model, - ) -} - -export interface Dependencies { - readonly loadPackage?: (specifier: string) => Effect.Effect - readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect -} - -export const fromCatalogModel = ( - model: ModelV2.Info, - credential?: Credential.Value, - dependencies: Dependencies = {}, -): Effect.Effect => { - const resolved = produce(model, (draft) => { - if (draft.settings?.apiKey === "") delete draft.settings.apiKey - if (credential?.type === "key" && credential.metadata !== undefined) - draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata) - }) - const packageName = ProviderV2.packageName(resolved.package) - const key = apiKey(resolved, credential) - - if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) { - return Effect.succeed(codexModel(resolved, credential, key)) - } - - if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") { - if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key)) - return Effect.succeed( - withDefaults(resolved, OpenAIResponses.route) - .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), - ) - } - if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") { - return Effect.succeed( - withDefaults(resolved, AnthropicMessages.route) - .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) - .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), - ) - } - if ( - ProviderV2.isAISDK(resolved.package) && - packageName === "@ai-sdk/openai-compatible" && - typeof resolved.settings?.baseURL === "string" - ) { - return Effect.succeed( - withDefaults(resolved, OpenAICompatibleChat.route) - .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }), - ) - } - if (ProviderV2.isAISDK(resolved.package)) { - if (!dependencies.loadAISDK) return Effect.fail(unsupported(resolved)) - const runtime = produce(resolved, (draft) => { - draft.settings = ProviderV2.mergeOverlay(draft.settings, { - ...(credential?.type === "key" ? { apiKey: credential.key } : {}), - ...(credential?.type === "oauth" ? { apiKey: credential.access } : {}), - ...credential?.metadata, - }) - }) - return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved))) - } - if (!resolved.package) return Effect.fail(unsupported(resolved)) - - const specifier = resolved.package - return Effect.gen(function* () { - const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe( - Effect.mapError(() => unsupported(resolved)), - ) - const configured = { ...resolved.settings, ...credential?.metadata } - const settings = { - ...(credential ? withoutNativeAuthSettings(configured) : configured), - ...nativeCredentialSettings(specifier, credential), - headers: resolved.headers, - body: resolved.body, - limits: { context: resolved.limit.context, output: resolved.limit.output }, - } - return yield* Effect.try({ - try: () => { - const runtime = module.model(resolved.modelID ?? resolved.id, settings) - return Model.update(runtime, { - provider: resolved.providerID, - compatibility: resolved.compatibility - ? { ...runtime.compatibility, ...resolved.compatibility } - : runtime.compatibility, - }) - }, - catch: () => unsupported(resolved), - }) - }) -} - -const isNativeOpenAI = (packageName: string | undefined) => - packageName === "@opencode-ai/ai/providers/openai" || - packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true - -const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => { - if (!credential) return {} - if (credential.type === "key") return { apiKey: credential.key } - if ( - specifier === "@opencode-ai/ai/providers/anthropic" || - specifier === "@opencode-ai/ai/providers/anthropic-compatible" - ) - return { authToken: credential.access } - if ( - specifier === "@opencode-ai/ai/providers/google-vertex" || - specifier.startsWith("@opencode-ai/ai/providers/google-vertex/") - ) - return { accessToken: credential.access } - return { apiKey: credential.access } -} - -const withoutNativeAuthSettings = (settings: Record) => { - const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings - return rest -} - -const codexModel = ( - model: ModelV2.Info, - credential: Credential.Value | undefined, - key: ReturnType | undefined, -) => { - const account = OpenAICodex.accountID(credential) - return withDefaults(model, OpenAIResponses.route) - .with({ - endpoint: { baseURL: OpenAICodex.baseURL }, - auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen( - account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }), - ), - }) - .model({ id: model.modelID ?? model.id, compatibility: model.compatibility }) -} - -const unsupported = (model: ModelV2.Info) => - new UnsupportedPackageError({ - providerID: model.providerID, - modelID: model.id, - package: model.package ?? "unknown", - }) - -export const resolve = ( - session: SessionSchema.Info, - model: ModelV2.Info, - credential?: Credential.Value, - dependencies?: Dependencies, -) => - withVariant(model, session.model?.variant).pipe( - Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)), - ) - -export const supported = (model: ModelV2.Info) => Boolean(model.package) - -/** Resolves models from the catalog belonging to the current Location runtime. */ const layer = Layer.effect( Service, Effect.gen(function* () { const catalog = yield* Catalog.Service - const integrations = yield* Integration.Service - const npm = yield* Npm.Service - const aisdk = yield* AISDK.Service + const resolver = yield* ModelResolver.Service return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { // Location plugins populate and filter the catalog asynchronously during layer startup. - const defaultModel = session.model ? undefined : yield* catalog.model.default() - const selected = session.model - ? (yield* catalog.model.available()).find( - (model) => model.providerID === session.model?.providerID && model.id === session.model.id, - ) - : defaultModel && supported(defaultModel) - ? defaultModel - : (yield* catalog.model.available()).find(supported) - if (!selected && session.model) + if (!session.model) { + const resolved = yield* resolver.resolve() + if (resolved) return resolved + return yield* new ModelNotSelectedError({ sessionID: session.id }) + } + const selected = (yield* catalog.model.available()).find( + (model) => model.providerID === session.model?.providerID && model.id === session.model.id, + ) + if (!selected) return yield* new ModelUnavailableError({ providerID: session.model.providerID, modelID: session.model.id, }) - if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) - const provider = yield* catalog.provider.get(selected.providerID) - const connection = yield* integrations.connection.active( - provider?.integrationID ?? Integration.ID.make(selected.providerID), - ) - const model = yield* resolve( - session, - selected, - connection ? yield* integrations.connection.resolve(connection) : undefined, - { - loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm), - loadAISDK: (model) => aisdk.model(model), - }, - ) - return { - model, - ref: ModelV2.Ref.make({ - id: selected.id, - providerID: selected.providerID, - ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), - }), - capabilities: selected.capabilities, - cost: selected.cost, - } + return yield* resolver.resolveModel(selected, session.model.variant) }), }) }), ) -export const node = makeLocationNode({ - service: Service, - layer, - deps: [Catalog.node, Integration.node, Npm.node, AISDK.node], -}) +export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, ModelResolver.node] }) diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts new file mode 100644 index 00000000000..d990f96acea --- /dev/null +++ b/packages/core/test/generate.test.ts @@ -0,0 +1,111 @@ +import { expect } from "bun:test" +import { LLMClient, LLMEvent, LLMResponse, Model } from "@opencode-ai/ai" +import { OpenAIChat } from "@opencode-ai/ai/protocols" +import { AISDK } from "@opencode-ai/core/aisdk" +import { Catalog } from "@opencode-ai/core/catalog" +import { Generate } from "@opencode-ai/core/generate" +import { Integration } from "@opencode-ai/core/integration" +import { ModelResolver } from "@opencode-ai/core/model-resolver" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Npm } from "@opencode-ai/util/npm" +import { Effect, Layer, Stream } from "effect" +import { testEffect } from "./lib/effect" + +const selected = ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("gemini")), + package: ProviderV2.aisdk("@ai-sdk/google"), +}) +const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route }) + +const catalog = Layer.mock(Catalog.Service, { + provider: { + get: () => Effect.succeed(undefined), + all: () => Effect.die("unused"), + available: () => Effect.die("unused"), + }, + model: { + get: () => Effect.succeed(selected), + all: () => Effect.die("unused"), + available: () => Effect.die("unused"), + default: () => Effect.die("unused"), + small: () => Effect.die("unused"), + }, +}) +const integrations = Layer.mock(Integration.Service, { + connection: { + active: () => Effect.succeed(undefined), + resolve: () => Effect.die("unused"), + key: () => Effect.die("unused"), + update: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }, + oauth: { + connect: () => Effect.die("unused"), + status: () => Effect.die("unused"), + complete: () => Effect.die("unused"), + cancel: () => Effect.die("unused"), + }, + command: { + connect: () => Effect.die("unused"), + status: () => Effect.die("unused"), + cancel: () => Effect.die("unused"), + }, +}) +const npm = Layer.mock(Npm.Service, { + add: () => Effect.die("unused"), + install: () => Effect.die("unused"), + which: () => Effect.die("unused"), +}) +const aisdk = Layer.mock(AISDK.Service, { + hook: { + sdk: () => Effect.die("unused"), + language: () => Effect.die("unused"), + }, + model: () => Effect.succeed(runtime), +}) +const client = Layer.mock(LLMClient.Service)({ + prepare: () => Effect.die("unused"), + stream: () => Stream.die("unused"), + generate: () => + Effect.sync(() => { + const response = LLMResponse.fromEvents([ + LLMEvent.textStart({ id: "generate" }), + LLMEvent.textDelta({ id: "generate", text: "OK" }), + LLMEvent.textEnd({ id: "generate" }), + LLMEvent.finish({ reason: "stop" }), + ]) + if (!response) throw new Error("Incomplete generate response") + return response + }), +}) + +const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk))) +const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client)))) +const resolverIt = testEffect(resolver) + +it.effect("loads dynamic AI SDK models", () => + Effect.gen(function* () { + const generate = yield* Generate.Service + const result = yield* generate.text({ + prompt: "Return exactly OK", + model: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }), + }) + + expect(result).toBe("OK") + }), +) + +resolverIt.effect("resolves dynamic models with their catalog metadata", () => + Effect.gen(function* () { + const resolver = yield* ModelResolver.Service + const result = yield* resolver.resolve(ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id })) + + expect(result).toEqual({ + model: runtime, + ref: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }), + capabilities: selected.capabilities, + cost: selected.cost, + }) + }), +) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/model-resolver.test.ts similarity index 80% rename from packages/core/test/session-runner-model.test.ts rename to packages/core/test/model-resolver.test.ts index 38fc707918c..9eedc4265b6 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -1,17 +1,13 @@ import { describe, expect } from "bun:test" import { LLM, Model } from "@opencode-ai/ai" import { LLMClient } from "@opencode-ai/ai/route" -import { DateTime, Effect } from "effect" -import { Money } from "@opencode-ai/schema/money" +import { 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" -import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" -import { SessionV2 } from "@opencode-ai/core/session" -import { AbsolutePath } from "@opencode-ai/core/schema" +import { ModelResolver } from "@opencode-ai/core/model-resolver" import { it } from "./lib/effect" interface ModelOptions { @@ -43,13 +39,13 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) => limit: { context: 100, output: 20 }, }) -describe("SessionRunnerModel", () => { +describe("ModelResolver", () => { it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () => Effect.gen(function* () { const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, }) - const resolved = yield* SessionRunnerModel.fromCatalogModel(catalog) + const resolved = yield* ModelResolver.fromCatalogModel(catalog) expect(catalog.id).toBe(ModelV2.ID.make("test-model")) expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" }) @@ -68,7 +64,7 @@ describe("SessionRunnerModel", () => { it.effect("keeps catalog apiKey credentials out of provider JSON", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { apiKey: "secret", baseURL: "https://openai.example/v1" }, }), @@ -82,7 +78,7 @@ describe("SessionRunnerModel", () => { it.effect("treats an empty configured API key as omitted", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { apiKey: "", baseURL: "https://openai.example/v1" }, }), @@ -101,7 +97,7 @@ describe("SessionRunnerModel", () => { it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), { compatibility: { reasoningField: "vendor_reasoning" }, settings: { @@ -130,7 +126,7 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("overlays selected OpenAI Session variant settings and bodies", () => + it.effect("overlays selected OpenAI variant settings and bodies", () => Effect.gen(function* () { const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, @@ -147,22 +143,7 @@ describe("SessionRunnerModel", () => { }, ], }) - const session = SessionV2.Info.make({ - id: SessionV2.ID.make("ses_model_variant"), - projectID: ProjectV2.ID.global, - title: "test", - model: { - id: catalog.id, - providerID: catalog.providerID, - variant: ModelV2.VariantID.make("high"), - }, - cost: Money.USD.zero, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, - location: { directory: AbsolutePath.make("/project") }, - }) - - const resolved = yield* SessionRunnerModel.resolve(session, catalog) + const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high")) expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" }) expect(resolved.route.defaults.http?.body).toEqual({ @@ -177,7 +158,7 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("overlays selected OpenAI-compatible Session variant bodies", () => + it.effect("overlays selected OpenAI-compatible variant bodies", () => Effect.gen(function* () { const catalog = model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), { settings: { baseURL: "https://compatible.example/v1" }, @@ -190,18 +171,7 @@ describe("SessionRunnerModel", () => { }, ], }) - const session = SessionV2.Info.make({ - id: SessionV2.ID.make("ses_compatible_variant"), - projectID: ProjectV2.ID.global, - title: "test", - model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, - cost: Money.USD.zero, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, - location: { directory: AbsolutePath.make("/project") }, - }) - - const resolved = yield* SessionRunnerModel.resolve(session, catalog) + const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high")) expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true }, @@ -211,27 +181,12 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("rejects an explicit unavailable Session variant during model resolution", () => + it.effect("rejects an explicit unavailable variant during model resolution", () => Effect.gen(function* () { const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, }) - const session = SessionV2.Info.make({ - id: SessionV2.ID.make("ses_model_variant_unavailable"), - projectID: ProjectV2.ID.global, - title: "test", - model: { - id: catalog.id, - providerID: catalog.providerID, - variant: ModelV2.VariantID.make("unknown"), - }, - cost: Money.USD.zero, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, - location: { directory: AbsolutePath.make("/project") }, - }) - - const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip) + const failure = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("unknown")).pipe(Effect.flip) expect(failure).toMatchObject({ _tag: "SessionRunnerModel.VariantUnavailableError", @@ -243,7 +198,7 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("overlays selected Anthropic Session variant settings", () => + it.effect("overlays selected Anthropic variant settings", () => Effect.gen(function* () { const catalog = model(ProviderV2.aisdk("@ai-sdk/anthropic"), { settings: { baseURL: "https://anthropic.example/v1" }, @@ -256,18 +211,7 @@ describe("SessionRunnerModel", () => { }, ], }) - const session = SessionV2.Info.make({ - id: SessionV2.ID.make("ses_anthropic_variant"), - projectID: ProjectV2.ID.global, - title: "test", - model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, - cost: Money.USD.zero, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, - location: { directory: AbsolutePath.make("/project") }, - }) - - const resolved = yield* SessionRunnerModel.resolve(session, catalog) + const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high")) expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true }, @@ -280,7 +224,7 @@ describe("SessionRunnerModel", () => { it.effect("maps catalog Anthropic AI SDK models into native routes", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/anthropic"), { settings: { baseURL: "https://anthropic.example/v1" }, }), @@ -296,7 +240,7 @@ describe("SessionRunnerModel", () => { it.effect("uses resolved credentials for bearer auth", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, headers: {}, @@ -320,7 +264,7 @@ describe("SessionRunnerModel", () => { it.effect("prefers stored credentials over configured auth", () => Effect.gen(function* () { const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }) - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" }, headers: {}, @@ -343,7 +287,7 @@ describe("SessionRunnerModel", () => { it.effect("does not project OAuth account metadata into the request body", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, headers: {}, @@ -365,7 +309,7 @@ describe("SessionRunnerModel", () => { it.effect("routes ChatGPT OAuth credentials to the codex backend", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, headers: {}, @@ -400,7 +344,7 @@ describe("SessionRunnerModel", () => { it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model("@opencode-ai/ai/providers/openai", { settings: { baseURL: "https://openai.example/v1" }, }), @@ -429,7 +373,7 @@ describe("SessionRunnerModel", () => { it.effect("does not route native OpenAI-compatible packages to the codex backend", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model("@opencode-ai/ai/providers/openai-compatible", { settings: { baseURL: "https://compatible.example/v1" }, }), @@ -450,7 +394,7 @@ describe("SessionRunnerModel", () => { it.effect("maps legacy OpenAI organization and project settings to headers", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { organization: "org_123", project: "proj_123" }, }), @@ -465,7 +409,7 @@ describe("SessionRunnerModel", () => { it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, headers: {}, @@ -496,7 +440,7 @@ describe("SessionRunnerModel", () => { it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () => Effect.gen(function* () { - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, headers: {}, @@ -528,12 +472,12 @@ describe("SessionRunnerModel", () => { it.effect("loads dynamic native provider packages through the injected package loader", () => Effect.gen(function* () { - const native = yield* SessionRunnerModel.fromCatalogModel( + const native = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, }), ) - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model("@opencode-ai/ai/providers/custom", { settings: { region: "test" }, headers: { "x-package": "header" }, @@ -565,7 +509,7 @@ describe("SessionRunnerModel", () => { it.effect("maps OAuth credentials to native provider auth settings", () => Effect.gen(function* () { - const native = yield* SessionRunnerModel.fromCatalogModel( + const native = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, }), @@ -588,7 +532,7 @@ describe("SessionRunnerModel", () => { ] as const yield* Effect.forEach(packages, ([specifier, key]) => - SessionRunnerModel.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, { + ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, { loadPackage: () => Effect.succeed({ model: (modelID, settings) => { @@ -604,12 +548,12 @@ describe("SessionRunnerModel", () => { it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () => Effect.gen(function* () { - const native = yield* SessionRunnerModel.fromCatalogModel( + const native = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, }), ) - const resolved = yield* SessionRunnerModel.fromCatalogModel( + const resolved = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/google"), { modelID: "gemini-api-model", settings: { project: "test" }, @@ -644,7 +588,7 @@ describe("SessionRunnerModel", () => { it.effect("rejects AISDK packages without an available loader", () => Effect.gen(function* () { - const failure = yield* SessionRunnerModel.fromCatalogModel( + const failure = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/google"), { settings: { baseURL: "https://google.example/v1" }, }), @@ -662,12 +606,12 @@ describe("SessionRunnerModel", () => { it.effect("drops an empty API key before loading an AISDK package", () => Effect.gen(function* () { - const native = yield* SessionRunnerModel.fromCatalogModel( + const native = yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai"), { settings: { baseURL: "https://openai.example/v1" }, }), ) - yield* SessionRunnerModel.fromCatalogModel( + yield* ModelResolver.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/google"), { settings: { apiKey: "", baseURL: "https://google.example/v1" }, }), @@ -685,9 +629,9 @@ describe("SessionRunnerModel", () => { it.effect("reports whether a catalog model declares a provider package", () => Effect.sync(() => { - expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true) - expect(SessionRunnerModel.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true) - expect(SessionRunnerModel.supported(model(undefined))).toBe(false) + expect(ModelResolver.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true) + expect(ModelResolver.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true) + expect(ModelResolver.supported(model(undefined))).toBe(false) }), ) }) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index d37cc83c126..3ad15fc730a 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -49,14 +49,15 @@ const client = Layer.mock(LLMClient.Service)({ generate: () => Effect.die("unused"), }) const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) -const models = SessionRunnerModel.layerWith(() => - Effect.succeed( - SessionRunnerModel.resolved(model, { - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - cost: [], - }), - ), -) +const models = Layer.mock(SessionRunnerModel.Service)({ + resolve: () => + Effect.succeed( + SessionRunnerModel.resolved(model, { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + cost: [], + }), + ), +}) const locations = Layer.effect( LocationServiceMap.Service, LayerMap.make( diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 3f0183167c4..98e7aedc0c7 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -66,14 +66,15 @@ const client = Layer.mock(LLMClient.Service)({ return response }), }) -const models = SessionRunnerModel.layerWith(() => - Effect.succeed( - SessionRunnerModel.resolved(model, { - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - cost: [], - }), - ), -) +const models = Layer.mock(SessionRunnerModel.Service)({ + resolve: () => + Effect.succeed( + SessionRunnerModel.resolved(model, { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + cost: [], + }), + ), +}) const builtins = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed( diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index d26fa37df44..fb3619e4c69 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -73,14 +73,15 @@ const model = OpenAIChat.route generation: { maxTokens: 20, temperature: 0 }, }) .model({ id: "gpt-4o-mini" }) -const models = SessionRunnerModel.layerWith(() => - Effect.succeed( - SessionRunnerModel.resolved(model, { - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - cost: [], - }), - ), -) +const models = Layer.mock(SessionRunnerModel.Service)({ + resolve: () => + Effect.succeed( + SessionRunnerModel.resolved(model, { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + cost: [], + }), + ), +}) const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) }) const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6ce9cf0f203..86e9d7c576d 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -280,17 +280,18 @@ const echo = Layer.effectDiscard( const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] }) let modelResolveHook = Effect.void let currentModel = model -const models = SessionRunnerModel.layerWith((session) => - modelResolveHook.pipe( - Effect.as( - SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, { - capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, - cost: [], - variant: session.model?.variant, - }), +const models = Layer.mock(SessionRunnerModel.Service)({ + resolve: (session) => + modelResolveHook.pipe( + Effect.as( + SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + cost: [], + variant: session.model?.variant, + }), + ), ), - ), -) +}) const systemContextKey = Instructions.Key.make("test/context") let systemBaseline = "Initial context" let systemRemoved = false diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 41e25953c41..4367d1b82e6 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -88,8 +88,8 @@ describe("search tools", () => { expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT }) expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT }) - expect(glob.output?.content).toEqual([{ type: "text", text: glob.result.value }]) - expect(grep.output?.content).toEqual([{ type: "text", text: grep.result.value }]) + expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }]) + expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }]) expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`) }), From f1f0f47ee22ae2972a7acef1b5d6dbb7e0578d1e Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 22 Jul 2026 22:38:13 -0400 Subject: [PATCH 11/30] fix(core): migrate named agent colors (#38414) --- packages/core/src/v1/config/agent.ts | 7 +++++-- packages/core/src/v1/config/migrate.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/v1/config/agent.ts b/packages/core/src/v1/config/agent.ts index 09838a91968..b220bd7ef87 100644 --- a/packages/core/src/v1/config/agent.ts +++ b/packages/core/src/v1/config/agent.ts @@ -4,7 +4,10 @@ import { Schema, SchemaGetter } from "effect" import { PositiveInt } from "../../schema" import { ConfigPermissionV1 } from "./permission" -const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) +const Color = Schema.Union([ + Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), + Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), +]) const AgentSchema = Schema.StructWithRest( Schema.Struct({ @@ -26,7 +29,7 @@ const AgentSchema = Schema.StructWithRest( }), options: Schema.optional(Schema.Record(Schema.String, Schema.Any)), color: Schema.optional(Color).annotate({ - description: "Hex color code (e.g., #FF5733)", + description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)", }), steps: Schema.optional(PositiveInt).annotate({ description: "Maximum number of agentic iterations before forcing text-only response", diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 6c0a342abe7..61a49f57cf4 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -161,7 +161,7 @@ export function migrateAgent(info: ConfigAgentV1.Info) { description: info.description, mode: info.mode, hidden: info.hidden, - color: info.color, + color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa", steps: info.steps, disabled: info.disable, permissions: permissions(info.permission), From 6e8aefcfa07fa49ea6c9988d371353b1b76d69f7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:42:53 -0500 Subject: [PATCH 12/30] fix(ai): normalize Bedrock cache usage (#38427) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/ai/src/protocols/bedrock-converse.ts | 19 +++---- packages/ai/src/schema/events.ts | 7 +-- ...s-cachepoint-on-identical-second-call.json | 53 +++++++++++++++++++ .../bedrock-converse-cache.recorded.test.ts | 22 +++++--- .../ai/test/provider/bedrock-converse.test.ts | 33 ++++++++++++ 5 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 packages/ai/test/fixtures/recordings/bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call.json diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index 801fb8a98a2..16052648a9f 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -436,21 +436,22 @@ const mapFinishReason = (reason: string): FinishReason => { return "unknown" } -// AWS Bedrock Converse reports `inputTokens` (inclusive total) with -// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass -// the total through and derive the non-cached breakdown. Bedrock does -// not break reasoning out of `outputTokens` for any current model. +// AWS reports inputTokens separately from cache reads and writes. +// Bedrock does not break reasoning out of outputTokens for current models. const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => { if (!usage) return undefined - const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0) - const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal) + const inputTokens = ProviderShared.sumTokens( + usage.inputTokens, + usage.cacheReadInputTokens, + usage.cacheWriteInputTokens, + ) return new Usage({ - inputTokens: usage.inputTokens, + inputTokens, outputTokens: usage.outputTokens, - nonCachedInputTokens: nonCached, + nonCachedInputTokens: usage.inputTokens, cacheReadInputTokens: usage.cacheReadInputTokens, cacheWriteInputTokens: usage.cacheWriteInputTokens, - totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens), + totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens), providerMetadata: { bedrock: usage }, }) } diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts index 5be1c4cf900..18454b84707 100644 --- a/packages/ai/src/schema/events.ts +++ b/packages/ai/src/schema/events.ts @@ -34,11 +34,12 @@ import { ProviderFailureClassification } from "./errors" * * **Semantics by provider**: * - * - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive + * - OpenAI Chat / Responses / Gemini: provider reports inclusive * `inputTokens` and an inclusive `outputTokens`; mapper subtracts to * derive the breakdown. - * - Anthropic: provider reports the breakdown natively (`input_tokens` is - * non-cached only); mapper sums to derive the inclusive `inputTokens`. + * - Anthropic and Bedrock report the input breakdown natively: Anthropic's + * `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their + * mappers sum the breakdown to derive the inclusive `inputTokens`. * Anthropic does *not* break extended-thinking out of `output_tokens`, so * `reasoningTokens` is `undefined` and `outputTokens` carries the * combined total — a documented limitation of the Anthropic API. diff --git a/packages/ai/test/fixtures/recordings/bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call.json b/packages/ai/test/fixtures/recordings/bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call.json new file mode 100644 index 00000000000..8fd307e2202 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:bedrock-converse-cache", + "provider:amazon-bedrock", + "protocol:bedrock-converse", + "cache" + ], + "name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call", + "recordedAt": "2026-07-23T02:29:10.955Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Say hi.\"}]}],\"system\":[{\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"},{\"cachePoint\":{\"type\":\"default\"}}],\"inferenceConfig\":{\"maxTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAiwAAAFImcW4yCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1uIiwicm9sZSI6ImFzc2lzdGFudCJ9uwonDAAAANUAAABX0TjrFws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJIaS4ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNCJ9BToCUgAAAJMAAABWcYx2aAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ubyJ9uOXHGAAAALAAAABRaYm2Hws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0SuCAcAAABgQAAAE6znPl5CzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTE5OH0sInAiOiJhYmNkZWYiLCJ1c2FnZSI6eyJjYWNoZURldGFpbHMiOlt7ImlucHV0VG9rZW5zIjo1NzUyLCJ0dGwiOiI1bSJ9XSwiY2FjaGVSZWFkSW5wdXRUb2tlbkNvdW50IjowLCJjYWNoZVJlYWRJbnB1dFRva2VucyI6MCwiY2FjaGVXcml0ZUlucHV0VG9rZW5Db3VudCI6NTc1MiwiY2FjaGVXcml0ZUlucHV0VG9rZW5zIjo1NzUyLCJpbnB1dFRva2VucyI6OSwib3V0cHV0VG9rZW5zIjoyLCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6NTc2M319YVPHOQ==", + "bodyEncoding": "base64" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Say hi.\"}]}],\"system\":[{\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"},{\"cachePoint\":{\"type\":\"default\"}}],\"inferenceConfig\":{\"maxTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAApgAAAFIfIIWHCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PIiwicm9sZSI6ImFzc2lzdGFudCJ9AcVkFwAAAKkAAABX7Rrm2Qs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJIaS4ifSwicCI6ImFiY2RlZmdoaWprbG0iffxI0NkAAACiAAAAVu3N514LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0QifWQnKBAAAACFAAAAUQBIgekLOmV2ZW50LXR5cGUHAAttZXNzYWdlU3RvcA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7InAiOiJhYmNkIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0c+t0FAAABTQAAAE6fefyjCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6OTcwfSwicCI6ImFiY2QiLCJ1c2FnZSI6eyJjYWNoZVJlYWRJbnB1dFRva2VuQ291bnQiOjU3NTIsImNhY2hlUmVhZElucHV0VG9rZW5zIjo1NzUyLCJjYWNoZVdyaXRlSW5wdXRUb2tlbkNvdW50IjowLCJjYWNoZVdyaXRlSW5wdXRUb2tlbnMiOjAsImlucHV0VG9rZW5zIjo5LCJvdXRwdXRUb2tlbnMiOjIsInNlcnZlclRvb2xVc2FnZSI6e30sInRvdGFsVG9rZW5zIjo1NzYzfX0J7IoM", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts index 8702e4eb403..8209ab1121a 100644 --- a/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts +++ b/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts @@ -13,12 +13,8 @@ const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1" // call wouldn't deterministically prove cache mapping works. Override with // BEDROCK_CACHE_MODEL_ID if your account has access elsewhere. const model = AmazonBedrock.configure({ - credentials: { - region: RECORDING_REGION, - accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture", - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture", - sessionToken: process.env.AWS_SESSION_TOKEN, - }, + apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture", + region: RECORDING_REGION, }).model(process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0") const cacheRequest = LLM.request({ @@ -36,7 +32,7 @@ const recorded = recordedTests({ prefix: "bedrock-converse-cache", provider: "amazon-bedrock", protocol: "bedrock-converse", - requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], + requires: ["AWS_BEARER_TOKEN_BEDROCK"], // Two identical requests in one cassette — replay walks the cassette in // recording order so the second call replays the cached-hit interaction. }) @@ -45,10 +41,20 @@ describe("Bedrock Converse cache recorded", () => { recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () => Effect.gen(function* () { const first = yield* LLMClient.generate(cacheRequest) - expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + expect(first.usage?.cacheWriteInputTokens ?? 0).toBeGreaterThan(0) + expect(first.usage?.inputTokens).toBe( + (first.usage?.nonCachedInputTokens ?? 0) + + (first.usage?.cacheReadInputTokens ?? 0) + + (first.usage?.cacheWriteInputTokens ?? 0), + ) const second = yield* LLMClient.generate(cacheRequest) expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + expect(second.usage?.inputTokens).toBe( + (second.usage?.nonCachedInputTokens ?? 0) + + (second.usage?.cacheReadInputTokens ?? 0) + + (second.usage?.cacheWriteInputTokens ?? 0), + ) }), ) }) diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index 59ca67333bf..776032966c8 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -269,6 +269,39 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("adds cache reads and writes to Bedrock input usage", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }], + ["contentBlockStop", { contentBlockIndex: 0 }], + ["messageStop", { stopReason: "end_turn" }], + [ + "metadata", + { + usage: { + inputTokens: 5, + outputTokens: 2, + totalTokens: 12, + cacheReadInputTokens: 3, + cacheWriteInputTokens: 2, + }, + }, + ], + ) + const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + + expect(response.usage).toMatchObject({ + inputTokens: 10, + nonCachedInputTokens: 5, + cacheReadInputTokens: 3, + cacheWriteInputTokens: 2, + outputTokens: 2, + totalTokens: 12, + }) + }), + ) + it.effect("assembles streamed tool call input", () => Effect.gen(function* () { const body = eventStreamBody( From b6f85c2250ba81d826ea118a8256db53a7a7d8b3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:20:16 -0500 Subject: [PATCH 13/30] fix(core): default custom model capabilities (#38449) Co-authored-by: Aiden Cline --- packages/core/src/catalog.ts | 2 +- packages/core/src/github-copilot/models.ts | 2 +- packages/core/test/aisdk.test.ts | 2 +- packages/core/test/config/provider.test.ts | 80 +++++++++++++++++++ packages/core/test/generate.test.ts | 2 +- .../core/test/github-copilot/models.test.ts | 4 +- .../plugin/provider-amazon-bedrock.test.ts | 42 +++++----- .../test/plugin/provider-anthropic.test.ts | 4 +- .../provider-azure-cognitive-services.test.ts | 12 +-- .../core/test/plugin/provider-azure.test.ts | 18 ++--- .../test/plugin/provider-cerebras.test.ts | 6 +- .../provider-cloudflare-ai-gateway.test.ts | 22 ++--- .../provider-cloudflare-workers-ai.test.ts | 12 +-- .../core/test/plugin/provider-dynamic.test.ts | 16 ++-- .../core/test/plugin/provider-factory.test.ts | 2 +- .../plugin/provider-github-copilot.test.ts | 30 +++---- .../core/test/plugin/provider-gitlab.test.ts | 16 ++-- .../provider-google-vertex-anthropic.test.ts | 16 ++-- .../plugin/provider-google-vertex.test.ts | 8 +- .../core/test/plugin/provider-google.test.ts | 8 +- .../plugin/provider-openai-compatible.test.ts | 10 +-- .../core/test/plugin/provider-openai.test.ts | 8 +- .../test/plugin/provider-opencode.test.ts | 14 ++-- .../test/plugin/provider-openrouter.test.ts | 4 +- .../test/plugin/provider-sap-ai-core.test.ts | 2 +- .../plugin/provider-snowflake-cortex.test.ts | 12 +-- .../core/test/plugin/provider-vercel.test.ts | 2 +- .../core/test/plugin/provider-xai.test.ts | 10 +-- packages/core/test/shared-schema.test.ts | 4 +- packages/docs/models.mdx | 12 +-- packages/schema/src/model.ts | 4 +- packages/schema/test/contract-hygiene.test.ts | 2 +- 32 files changed, 235 insertions(+), 153 deletions(-) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 9f634447907..9b8f916e707 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -116,7 +116,7 @@ const layer = Layer.effect( draft.providers.set(providerID, record) } const model = - record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo) + record.models.get(modelID) ?? (ModelV2.Info.default(providerID, modelID) as ModelV2.MutableInfo) if (!record.models.has(modelID)) record.models.set(modelID, model) fn(model) model.id = modelID diff --git a/packages/core/src/github-copilot/models.ts b/packages/core/src/github-copilot/models.ts index 8790cefba2c..52f98753b5f 100644 --- a/packages/core/src/github-copilot/models.ts +++ b/packages/core/src/github-copilot/models.ts @@ -135,7 +135,7 @@ function build(id: ModelV2.ID, remote: UsableModel, baseURL: string, previous?: const released = previous?.time.released || Date.parse(version) return ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, id), + ...ModelV2.Info.default(ProviderV2.ID.githubCopilot, id), id, modelID: ModelV2.ID.make(remote.id), providerID: ProviderV2.ID.githubCopilot, diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 0bc16e8f597..9e7e0d0c6ea 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -12,7 +12,7 @@ const it = testEffect(AISDK.locationLayer) const model = (packageName: string, settings: Record = {}) => ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("catalog-model")), + ...ModelV2.Info.default(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("catalog-model")), modelID: ModelV2.ID.make("api-model"), package: ProviderV2.aisdk(packageName), settings, diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index c3c5b9c4205..60597836f2d 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -49,6 +49,86 @@ function withEnv(vars: Record, effect: () = const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProviderPlugin.Plugin", () => { + it.effect("defaults custom models to agent capabilities", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("custom") + const modelID = ModelV2.ID.make("chat") + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai-compatible", + models: { chat: {} }, + }, + }, + }), + }), + ]), + }) + + yield* addPlugin(config) + + const model = required(yield* catalog.model.get(providerID, modelID)) + expect(model.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] }) + }), + ) + + it.effect("preserves catalog capabilities unless config overrides them", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("custom") + const inheritedID = ModelV2.ID.make("inherited") + const overriddenID = ModelV2.ID.make("overridden") + yield* catalog.transform((draft) => { + draft.model.update(providerID, inheritedID, (model) => { + model.capabilities = { tools: false, input: ["text"], output: ["text"] } + }) + draft.model.update(providerID, overriddenID, (model) => { + model.capabilities = { tools: false, input: ["text"], output: ["text"] } + }) + }) + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai-compatible", + models: { + inherited: { name: "Inherited" }, + overridden: { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + }, + }, + }, + }, + }), + }), + ]), + }) + + yield* addPlugin(config) + + expect((yield* catalog.model.get(providerID, inheritedID))?.capabilities).toEqual({ + tools: false, + input: ["text"], + output: ["text"], + }) + expect((yield* catalog.model.get(providerID, overriddenID))?.capabilities).toEqual({ + tools: true, + input: ["text", "image"], + output: ["text"], + }) + }), + ) + it.effect("keeps configured model variant bodies unchanged", () => Effect.gen(function* () { const catalog = yield* Catalog.Service diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index d990f96acea..c0ad30d3831 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -13,7 +13,7 @@ import { Effect, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" const selected = ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("gemini")), + ...ModelV2.Info.default(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("gemini")), package: ProviderV2.aisdk("@ai-sdk/google"), }) const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route }) diff --git a/packages/core/test/github-copilot/models.test.ts b/packages/core/test/github-copilot/models.test.ts index 31d8f710cd2..9bd07971fdf 100644 --- a/packages/core/test/github-copilot/models.test.ts +++ b/packages/core/test/github-copilot/models.test.ts @@ -49,12 +49,12 @@ test("defensively syncs advertised Copilot models", async () => { try { const existing = ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), name: "GPT-5 local", }) const stale = ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")), + ...ModelV2.Info.default(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")), modelID: ModelV2.ID.make("stale"), }) const models = await CopilotModels.get(server.url.origin, {}, [existing, stale]) diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index db63910c33f..39f46c49a2c 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -108,7 +108,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -134,7 +134,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -169,7 +169,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -190,7 +190,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -210,7 +210,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -230,7 +230,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -251,7 +251,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -281,7 +281,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -310,7 +310,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), modelID: ModelV2.ID.make("openai.gpt-5.5"), package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"), }), @@ -338,7 +338,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), modelID: ModelV2.ID.make("openai.gpt-5.5"), package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"), }), @@ -347,7 +347,7 @@ describe("AmazonBedrockPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), modelID: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"), }), @@ -365,7 +365,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/anthropic"), }), @@ -393,7 +393,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -425,7 +425,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -434,7 +434,7 @@ describe("AmazonBedrockPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -443,7 +443,7 @@ describe("AmazonBedrockPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -452,7 +452,7 @@ describe("AmazonBedrockPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -461,7 +461,7 @@ describe("AmazonBedrockPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -487,7 +487,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -574,7 +574,7 @@ describe("AmazonBedrockPlugin", () => { for (const item of cases) { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), modelID: ModelV2.ID.make(item.modelID), package: ProviderV2.aisdk("test-provider"), }), @@ -594,7 +594,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index af7b80dc673..0c0bcba461e 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -63,7 +63,7 @@ describe("AnthropicPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), modelID: ModelV2.ID.make("claude-sonnet-4-5"), package: ProviderV2.aisdk("@ai-sdk/anthropic"), }), @@ -81,7 +81,7 @@ describe("AnthropicPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), modelID: ModelV2.ID.make("claude-sonnet-4-5"), package: ProviderV2.aisdk("@ai-sdk/anthropic"), }), diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index f52f013105d..5b9ca4bf86f 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -121,7 +121,7 @@ describe("AzureCognitiveServicesPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: "aisdk:test-provider", }), @@ -140,7 +140,7 @@ describe("AzureCognitiveServicesPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: "aisdk:test-provider", }), @@ -149,7 +149,7 @@ describe("AzureCognitiveServicesPlugin", () => { }) const ignored = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: "aisdk:test-provider", }), @@ -170,7 +170,7 @@ describe("AzureCognitiveServicesPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")), + ...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")), modelID: ModelV2.ID.make("messages-deployment"), package: "aisdk:test-provider", }), @@ -179,7 +179,7 @@ describe("AzureCognitiveServicesPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), + ...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), modelID: ModelV2.ID.make("chat-deployment"), package: "aisdk:test-provider", }), @@ -188,7 +188,7 @@ describe("AzureCognitiveServicesPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")), + ...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")), modelID: ModelV2.ID.make("language-deployment"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 6cf515c0c60..227e3bfa783 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -148,7 +148,7 @@ describe("AzurePlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -168,7 +168,7 @@ describe("AzurePlugin", () => { const exit = yield* aisdk .runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -189,7 +189,7 @@ describe("AzurePlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -208,7 +208,7 @@ describe("AzurePlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -227,7 +227,7 @@ describe("AzurePlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), body: { useCompletionUrls: true }, @@ -247,7 +247,7 @@ describe("AzurePlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -256,7 +256,7 @@ describe("AzurePlugin", () => { }) const ignored = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -280,7 +280,7 @@ describe("AzurePlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), modelID: ModelV2.ID.make("messages-deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -289,7 +289,7 @@ describe("AzurePlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), modelID: ModelV2.ID.make("language-deployment"), package: ProviderV2.aisdk("test-provider"), }), diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index eb5c4ec1bf5..6722f48996f 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -65,7 +65,7 @@ describe("CerebrasPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("custom-cerebras"), ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), ), @@ -88,7 +88,7 @@ describe("CerebrasPlugin", () => { yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("custom-cerebras"), ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), ), @@ -110,7 +110,7 @@ describe("CerebrasPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("custom-cerebras"), ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), ), diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index cbd2b95c9eb..bd9d9e80cd2 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -117,7 +117,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -139,7 +139,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -184,7 +184,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -214,7 +214,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -252,7 +252,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -284,7 +284,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -307,7 +307,7 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -331,7 +331,7 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -361,7 +361,7 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -385,7 +385,7 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("anthropic/claude-sonnet-4-5"), ), @@ -417,7 +417,7 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index d67b8d91cc2..5f4e3bc2760 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -94,7 +94,7 @@ describe("CloudflareWorkersAIPlugin", () => { const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))) const sdk = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), modelID: ModelV2.ID.make("@cf/model"), package: provider.package, settings: provider.settings, @@ -138,7 +138,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), modelID: ModelV2.ID.make("@cf/model"), package: "aisdk:@ai-sdk/openai-compatible", settings: { baseURL: "https://proxy.example/v1" }, @@ -178,7 +178,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), modelID: ModelV2.ID.make("@cf/model"), package: "aisdk:@ai-sdk/openai-compatible", settings: { baseURL: "https://proxy.example/v1" }, @@ -207,7 +207,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), modelID: ModelV2.ID.make("@cf/model"), package: "aisdk:@ai-sdk/openai-compatible", settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" }, @@ -233,7 +233,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("@cf/api-model"), package: "aisdk:test-provider", }), @@ -253,7 +253,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), modelID: ModelV2.ID.make("@cf/model"), package: "aisdk:@ai-sdk/anthropic", settings: { baseURL: "https://proxy.example/v1" }, diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index d0b1a8af5ac..341878e6ded 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -53,7 +53,7 @@ describe("DynamicProviderPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), modelID: ModelV2.ID.make("test-model"), package: ProviderV2.aisdk(fixtureProvider), }), @@ -72,7 +72,7 @@ describe("DynamicProviderPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), modelID: ModelV2.ID.make("test-model"), package: ProviderV2.aisdk(fixtureProvider), }), @@ -90,7 +90,7 @@ describe("DynamicProviderPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), modelID: ModelV2.ID.make("test-model"), package: ProviderV2.aisdk(fixtureProvider), }), @@ -107,7 +107,7 @@ describe("DynamicProviderPlugin", () => { yield* addPlugin(npmEntrypoint(fixtureProviderPath)) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), + ...ModelV2.Info.default(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), modelID: ModelV2.ID.make("test-model"), package: "aisdk:fixture-provider", }), @@ -125,7 +125,7 @@ describe("DynamicProviderPlugin", () => { const exit = yield* aisdk .language( ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("alias"), package: "aisdk:fixture-provider", }), @@ -143,7 +143,7 @@ describe("DynamicProviderPlugin", () => { const exit = yield* aisdk .language( ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("alias"), package: "aisdk:file:///missing/provider-factory.js", }), @@ -163,7 +163,7 @@ describe("DynamicProviderPlugin", () => { const exit = yield* aisdk .language( ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("alias"), package: "aisdk:fixture-provider", }), @@ -181,7 +181,7 @@ describe("DynamicProviderPlugin", () => { yield* addPlugin() const language = yield* aisdk.language( ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("test-model-api"), package: ProviderV2.aisdk(fixtureProvider), }), diff --git a/packages/core/test/plugin/provider-factory.test.ts b/packages/core/test/plugin/provider-factory.test.ts index a884fd0ce0f..d51d4900b49 100644 --- a/packages/core/test/plugin/provider-factory.test.ts +++ b/packages/core/test/plugin/provider-factory.test.ts @@ -41,7 +41,7 @@ providers.forEach((item) => const host = yield* PluginHost.make(plugin) yield* item.plugin.effect(host) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make(item.id), modelID), + ...ModelV2.Info.default(ProviderV2.ID.make(item.id), modelID), modelID, package: ProviderV2.aisdk(item.package), }) diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index bb2443ae412..b39e06ac7be 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -99,7 +99,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", }), @@ -108,7 +108,7 @@ describe("GithubCopilotPlugin", () => { }) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", }), @@ -128,7 +128,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), modelID: ModelV2.ID.make("claude-sonnet-4"), package: "aisdk:test-provider", }), @@ -147,7 +147,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("claude-sonnet-4"), package: "aisdk:test-provider", }), @@ -166,7 +166,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", }), @@ -175,7 +175,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), modelID: ModelV2.ID.make("gpt-5.1-codex"), package: "aisdk:test-provider", }), @@ -184,7 +184,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), modelID: ModelV2.ID.make("gpt-4o"), package: "aisdk:test-provider", }), @@ -193,7 +193,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), modelID: ModelV2.ID.make("gpt-5-mini"), package: "aisdk:test-provider", }), @@ -202,7 +202,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), modelID: ModelV2.ID.make("gpt-5-mini-2025-08-07"), package: "aisdk:test-provider", }), @@ -227,7 +227,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")), modelID: ModelV2.ID.make("mai-code-1-flash-picker"), package: "aisdk:test-provider", settings: { endpoint: "responses" }, @@ -237,7 +237,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", settings: { endpoint: "chat" }, @@ -257,7 +257,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", }), @@ -266,7 +266,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), modelID: ModelV2.ID.make("gpt-5-mini"), package: "aisdk:test-provider", }), @@ -275,7 +275,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), modelID: ModelV2.ID.make("claude-sonnet-4"), package: "aisdk:test-provider", }), @@ -324,7 +324,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index ae66a61aa81..ac12ebfc9d6 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -69,7 +69,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), modelID: ModelV2.ID.make("claude"), package: "aisdk:test-provider", }), @@ -107,7 +107,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), modelID: ModelV2.ID.make("claude"), package: "aisdk:test-provider", }), @@ -133,7 +133,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), modelID: ModelV2.ID.make("claude"), package: "aisdk:test-provider", }), @@ -175,7 +175,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), modelID: ModelV2.ID.make("claude"), package: "aisdk:test-provider", }), @@ -195,7 +195,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), modelID: ModelV2.ID.make("duo-workflow-custom"), package: "aisdk:test-provider", headers: {}, @@ -229,7 +229,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), modelID: ModelV2.ID.make("duo-workflow-exact"), package: "aisdk:test-provider", }), @@ -257,7 +257,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), modelID: ModelV2.ID.make("duo-workflow-custom"), package: "aisdk:test-provider", headers: {}, @@ -284,7 +284,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), modelID: ModelV2.ID.make("claude"), package: "aisdk:test-provider", headers: { h: "v" }, diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index 090abb945c2..3b6726d3730 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -116,7 +116,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make("claude-sonnet-4-5"), ), @@ -143,7 +143,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make("claude-sonnet-4-5"), ), @@ -167,7 +167,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), modelID: ModelV2.ID.make("claude-sonnet-4-5"), package: "aisdk:test-provider", }), @@ -187,7 +187,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), modelID: ModelV2.ID.make("claude-sonnet-4-5"), package: "aisdk:test-provider", }), @@ -206,7 +206,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const sdkResult = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), modelID: ModelV2.ID.make(" claude-sonnet-4-5 "), package: "aisdk:test-provider", }), @@ -215,7 +215,7 @@ describe("GoogleVertexAnthropicPlugin", () => { }) const languageResult = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), modelID: ModelV2.ID.make(" claude-sonnet-4-5 "), package: "aisdk:test-provider", }), @@ -238,7 +238,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")), modelID: ModelV2.ID.make(" claude-sonnet-4-5 "), package: "aisdk:test-provider", }), @@ -257,7 +257,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), modelID: ModelV2.ID.make("claude-sonnet-4-5"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index 635beba3c1c..15949b49454 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -172,7 +172,7 @@ describe("GoogleVertexPlugin", () => { const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), modelID: ModelV2.ID.make("gemini"), package: "aisdk:@ai-sdk/google-vertex", }), @@ -294,7 +294,7 @@ describe("GoogleVertexPlugin", () => { yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), modelID: ModelV2.ID.make("gemini"), package: "aisdk:@ai-sdk/google-vertex", }), @@ -339,7 +339,7 @@ describe("GoogleVertexPlugin", () => { () => aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), modelID: ModelV2.ID.make("gemini"), package: "aisdk:@ai-sdk/openai-compatible", }), @@ -367,7 +367,7 @@ describe("GoogleVertexPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), modelID: ModelV2.ID.make(" gemini-2.5-pro "), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index d04e00e3b32..2dea12a2e66 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -26,7 +26,7 @@ describe("GooglePlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), modelID: ModelV2.ID.make("gemini"), package: "aisdk:@ai-sdk/google", }), @@ -45,7 +45,7 @@ describe("GooglePlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), + ...ModelV2.Info.default(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), modelID: ModelV2.ID.make("gemini"), package: "aisdk:@ai-sdk/google", }), @@ -63,7 +63,7 @@ describe("GooglePlugin", () => { yield* addPlugin() const sdkEvent = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("gemini-api"), package: "aisdk:@ai-sdk/google", }), @@ -88,7 +88,7 @@ describe("GooglePlugin", () => { const resolved = yield* aisdk.model( ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("gemini-api"), package: "aisdk:@ai-sdk/google", settings: { apiKey: "test" }, diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index a954af9f00a..e1cf1ed6c86 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -26,7 +26,7 @@ describe("OpenAICompatiblePlugin", () => { yield* addPlugin() const defaulted = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), modelID: ModelV2.ID.make("model"), package: "aisdk:test-provider", }), @@ -35,7 +35,7 @@ describe("OpenAICompatiblePlugin", () => { }) const disabled = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), modelID: ModelV2.ID.make("model"), package: "aisdk:test-provider", }), @@ -54,7 +54,7 @@ describe("OpenAICompatiblePlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), modelID: ModelV2.ID.make("model"), package: "aisdk:test-provider", }), @@ -78,7 +78,7 @@ describe("OpenAICompatiblePlugin", () => { ) yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), modelID: ModelV2.ID.make("model"), package: "aisdk:test-provider", }), @@ -99,7 +99,7 @@ describe("OpenAICompatiblePlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), modelID: ModelV2.ID.make("model"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index cd0c943442f..741966eebed 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -68,7 +68,7 @@ describe("OpenAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -86,7 +86,7 @@ describe("OpenAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -105,7 +105,7 @@ describe("OpenAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("gpt-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -125,7 +125,7 @@ describe("OpenAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: ProviderV2.aisdk("test-provider"), }), diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 1de22a03ddd..f0b9fff8d7a 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -293,7 +293,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")), modelID: ModelV2.ID.make("paid"), package: ProviderV2.aisdk("test-provider"), cost: cost(1), @@ -320,7 +320,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("free")), modelID: ModelV2.ID.make("free"), package: ProviderV2.aisdk("test-provider"), cost: cost(0), @@ -347,7 +347,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("output-only")), modelID: ModelV2.ID.make("output-only"), package: ProviderV2.aisdk("test-provider"), cost: cost(0, 1), @@ -376,7 +376,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")), modelID: ModelV2.ID.make("paid"), package: ProviderV2.aisdk("test-provider"), cost: cost(1), @@ -410,7 +410,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")), modelID: ModelV2.ID.make("paid"), package: ProviderV2.aisdk("test-provider"), cost: cost(1), @@ -438,7 +438,7 @@ describe("OpencodePlugin", () => { settings: { apiKey: "configured" }, }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")), modelID: ModelV2.ID.make("paid"), package: ProviderV2.aisdk("test-provider"), cost: cost(1), @@ -468,7 +468,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")), modelID: ModelV2.ID.make("paid"), package: ProviderV2.aisdk("test-provider"), cost: cost(1), diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 21ee3ae216a..63520e01d3f 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -54,7 +54,7 @@ describe("OpenRouterPlugin", () => { const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -65,7 +65,7 @@ describe("OpenRouterPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: ProviderV2.aisdk("test-provider"), }), diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 26dc3ac86cf..09d99867f2c 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -48,7 +48,7 @@ function withEnv(vars: Record, effect: () = function model(providerID: string) { return ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")), + ...ModelV2.Info.default(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")), modelID: ModelV2.ID.make("sap-model"), package: ProviderV2.aisdk(fixtureProvider), }) diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index 92af6237566..749f4c5520f 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -58,7 +58,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), + ...ModelV2.Info.default(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), modelID: ModelV2.ID.make("gpt-4"), package: "aisdk:test-provider", }), @@ -77,7 +77,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + ...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), modelID: ModelV2.ID.make("claude-sonnet-4-6"), package: "aisdk:test-provider", }), @@ -97,7 +97,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + ...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), modelID: ModelV2.ID.make("claude-sonnet-4-6"), package: "aisdk:test-provider", }), @@ -121,7 +121,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + ...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), modelID: ModelV2.ID.make("claude-sonnet-4-6"), package: "aisdk:test-provider", }), @@ -141,7 +141,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + ...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), modelID: ModelV2.ID.make("claude-sonnet-4-6"), package: "aisdk:test-provider", }), @@ -165,7 +165,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + ...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), modelID: ModelV2.ID.make("claude-sonnet-4-6"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index 46d5fe25b80..29820b714c5 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -59,7 +59,7 @@ describe("VercelPlugin", () => { yield* addPlugin() const event = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), modelID: ModelV2.ID.make("v0-1.0-md"), package: "aisdk:@ai-sdk/vercel", }), diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index 04bfc508f76..4b3e672c1ed 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -62,7 +62,7 @@ describe("XAIPlugin", () => { const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + ...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), modelID: ModelV2.ID.make("grok-4"), package: "aisdk:@ai-sdk/xai", }), @@ -72,7 +72,7 @@ describe("XAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + ...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), modelID: ModelV2.ID.make("grok-4"), package: "aisdk:@ai-sdk/xai", }), @@ -92,7 +92,7 @@ describe("XAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), modelID: ModelV2.ID.make("grok-4"), package: "aisdk:@ai-sdk/xai", }), @@ -112,7 +112,7 @@ describe("XAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("grok-4"), package: "aisdk:@ai-sdk/xai", }), @@ -133,7 +133,7 @@ describe("XAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), modelID: ModelV2.ID.make("grok-4"), package: "aisdk:@ai-sdk/xai", }), diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 37b67c63a82..937cd3e1f04 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -170,8 +170,8 @@ test("Core reuses the canonical shared schemas", async () => { for (const [core, shared] of schemas) expect(core).toBe(shared) expect(Agent.Info.empty(Agent.ID.make("test"))).toEqual(AgentV2.Info.empty(AgentV2.ID.make("test"))) - expect(Model.Info.empty(Provider.ID.make("test"), Model.ID.make("model"))).toEqual( - ModelV2.Info.empty(ProviderV2.ID.make("test"), ModelV2.ID.make("model")), + expect(Model.Info.default(Provider.ID.make("test"), Model.ID.make("model"))).toEqual( + ModelV2.Info.default(ProviderV2.ID.make("test"), ModelV2.ID.make("model")), ) expect(Provider.Info.empty(Provider.ID.make("test"))).toEqual(ProviderV2.Info.empty(ProviderV2.ID.make("test"))) expect(Skill.Source.key(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/tmp") }))).toBe( diff --git a/packages/docs/models.mdx b/packages/docs/models.mdx index 60fd63812ad..213bfac523e 100644 --- a/packages/docs/models.mdx +++ b/packages/docs/models.mdx @@ -94,9 +94,10 @@ You can also map a friendly catalog ID to a different API model ID with `modelID } ``` -Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2` is sent to the provider. When adding a -model that is not already in the catalog, set accurate `capabilities` and `limit` values so OpenCode can expose tools and -enforce the correct context limits. Set `disabled: true` on a model entry to hide it from the available catalog. +Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2` is sent to the provider. A model that is +not already in the catalog defaults to tool support, text and image input, and text output. Set accurate `capabilities` +and `limit` values when those defaults do not match the model or OpenCode needs to enforce its context limits. Set +`disabled: true` on a model entry to hide it from the available catalog. OpenAI-compatible models that stream reasoning through a custom assistant-message field can set `compatibility.reasoningField`: @@ -193,8 +194,9 @@ For an OpenAI-compatible server, define a provider package, endpoint, and at lea } ``` -Use the server's real model name, limits, modalities, and tool support. OpenCode cannot infer these for a model you add -manually. If the endpoint requires a key, add `apiKey` to provider `settings` using an environment substitution such as +Use the server's real model name, limits, modalities, and tool support. OpenCode applies the custom-model capability +defaults described above but cannot infer the server's actual limits or whether those defaults are accurate. If the +endpoint requires a key, add `apiKey` to provider `settings` using an environment substitution such as `"apiKey": "{env:LOCAL_API_KEY}"`; do not commit secrets. ### Model references diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 81caea654fc..2f9d1dd7cc9 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -106,13 +106,13 @@ export const Info = Schema.Struct({ .annotate({ identifier: "Model.Info" }) .pipe( statics(() => ({ - empty: (providerID: Provider.ID, id: ID) => + default: (providerID: Provider.ID, id: ID) => ({ id, modelID: id, providerID, name: id, - capabilities: { tools: false, input: [], output: [] }, + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, variants: [], time: { released: 0 }, cost: [], diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index c6628702690..46784ee27d5 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -83,7 +83,7 @@ describe("contract hygiene", () => { test("model defaults and provider overlays preserve public invariants", () => { const id = Model.ID.make("model") - expect(Model.Info.empty(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] }) + expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] }) expect(() => Schema.decodeUnknownSync(Provider.Info)({ id: "provider", From 52c98a4eeb927eefc07652abdd79eab1e1269e8f Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 23 Jul 2026 12:12:25 +0200 Subject: [PATCH 14/30] mini: add replay settings to cli config (#38487) --- packages/cli/src/commands/commands.ts | 6 +++--- packages/cli/src/commands/handlers/mini.ts | 4 ++-- packages/cli/test/mini.test.ts | 6 +++++- packages/tui/src/config/index.tsx | 6 ++++++ packages/tui/test/config-v2.test.tsx | 12 ++++++++++++ 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index c0b914fc62b..a3ae15113fa 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -140,11 +140,11 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.withDefault(false), ), replay: Flag.boolean("replay").pipe( - Flag.withDescription("Replay session history on resume and after resize"), - Flag.withDefault(true), + Flag.withDescription("Restore session history on resume and resize (disable with --no-replay)"), + Flag.optional, ), replayLimit: Flag.integer("replay-limit").pipe( - Flag.withDescription("Cap visible replay to the newest N messages"), + Flag.withDescription("Limit replay to the newest N messages (default: 200)"), Flag.optional, ), model: Flag.string("model").pipe( diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index 4f8a16a3501..972c3bf962a 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -28,8 +28,8 @@ export default Runtime.handler(Commands.commands.mini, (input) => model: Option.getOrUndefined(input.model), agent: Option.getOrUndefined(input.agent), prompt: Option.getOrUndefined(input.prompt), - replay: input.replay, - replayLimit: Option.getOrUndefined(input.replayLimit), + replay: Option.getOrUndefined(input.replay) ?? resolved.mini?.replay ?? true, + replayLimit: Option.getOrUndefined(input.replayLimit) ?? resolved.mini?.replay_limit, demo: input.demo, tuiConfig: resolved, config: { diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 34cf2a2cd27..e9304d194fa 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -214,11 +214,15 @@ describe("mini command", () => { expect(result.exitCode).toBe(0) expect(result.stdout).toContain("--server string") expect(result.stdout).toContain("--prompt string") + expect(result.stdout).toContain("--replay") + expect(result.stdout).toContain("disable with --no-replay") + expect(result.stdout).toContain("--replay-limit integer") + expect(result.stdout).toContain("Limit replay to the newest N messages (default: 200)") expect(result.stdout).not.toContain("SUBCOMMANDS") }) test("routes local and explicit-server invocations into mini", async () => { - for (const args of [["mini"], ["mini", "--server", "http://127.0.0.1:1"]]) { + for (const args of [["mini"], ["mini", "--no-replay"], ["mini", "--server", "http://127.0.0.1:1"]]) { const result = await cli(args) expect(result.exitCode).toBe(1) diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 3d668183900..e078eb5113a 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -142,6 +142,12 @@ export const Info = Schema.Struct({ mono: Schema.optional(Schema.Boolean).annotate({ description: "Use monochrome ASCII output", }), + replay: Schema.optional(Schema.Boolean).annotate({ + description: "Restore session history on resume and terminal resize", + }), + replay_limit: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({ + description: "Maximum number of newest messages restored during replay", + }), }), ).annotate({ description: "Mini transcript presentation settings" }), hints: Schema.optional( diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 7a663cc1179..c349ea52243 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -1,13 +1,25 @@ /** @jsxImportSource @opentui/solid */ import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" +import { Schema } from "effect" import { resolve, ConfigProvider, + Info, useConfig, type Interface, } from "../src/config" +test("validates mini replay settings", () => { + const decode = Schema.decodeUnknownSync(Info) + + expect(decode({ mini: { replay: false, replay_limit: 50 } })).toEqual({ + mini: { replay: false, replay_limit: 50 }, + }) + expect(() => decode({ mini: { replay_limit: 0 } })).toThrow() + expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow() +}) + test("resolves nested config and keybind defaults", () => { const config = resolve( { From 5b1321a8ca81bcedc19bfb7782472c35c5d77d38 Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 23 Jul 2026 10:00:51 -0400 Subject: [PATCH 15/30] feat(tui): add turn token usage diagnostics (#38398) --- packages/tui/src/component/devtools-bar.tsx | 13 ++- packages/tui/src/component/dialog-config.tsx | 8 -- packages/tui/src/config/index.tsx | 1 + packages/tui/src/routes/session/index.tsx | 114 ++++++++++++++++++- packages/tui/src/routes/session/rows.ts | 51 ++++++++- 5 files changed, 175 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx index fc168260a20..03373110e22 100644 --- a/packages/tui/src/component/devtools-bar.tsx +++ b/packages/tui/src/component/devtools-bar.tsx @@ -59,6 +59,7 @@ export function DevToolsBar() { const canSwitchMode = () => supports(nextMode()) const runtime = createMemo(() => runtimeStatus(frontendSamples())) const timing = () => config.data.debug?.timing ?? false + const turnTokens = () => config.data.debug?.turn_tokens ?? false const offEscape = keymap.intercept( "key", @@ -352,6 +353,16 @@ export function DevToolsBar() { > {timing() ? "[x]" : "[ ]"} Time to first draw + + void config.update((draft) => { + draft.debug = { ...draft.debug, turn_tokens: !turnTokens() } + }) + } + hoverBackground + > + {turnTokens() ? "[x]" : "[ ]"} Turn token usage + {(group) => ( @@ -403,7 +414,7 @@ function PanelBox(props: ParentProps) { position="absolute" zIndex={2600} bottom={1} - left={0} + left={-1} width={42} paddingLeft={2} paddingRight={2} diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index ab84fdf6233..8ff9db50c70 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -222,14 +222,6 @@ const settings: Setting[] = [ values: [false, true], labels: ["off", "on"], }, - { - title: "DevTools: Timing", - category: "Debug", - path: ["debug", "timing"], - default: true, - values: [false, true], - labels: ["off", "on"], - }, ] export function DialogConfig() { diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index e078eb5113a..424e8dbc9a8 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -159,6 +159,7 @@ export const Info = Schema.Struct({ Schema.Struct({ devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }), timing: Schema.optional(Schema.Boolean).annotate({ description: "Show time-to-first-draw diagnostics" }), + turn_tokens: Schema.optional(Schema.Boolean).annotate({ description: "Show per-turn token usage diagnostics" }), }), ).annotate({ description: "Debugging settings" }), animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }), diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 69c9682ec15..e07947ba1c4 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1029,11 +1029,23 @@ export function Session() { ) } -function SessionRowView(props: { +type SessionRowViewProps = { row: SessionRow message: (messageID: string) => SessionMessageInfo | undefined boundaryID?: string -}) { +} + +function SessionRowView(props: SessionRowViewProps) { + const config = useConfig() + const hidden = () => props.row.type === "turn-usage" && config.data.debug?.turn_tokens !== true + return ( + + + + ) +} + +function SessionRowContent(props: SessionRowViewProps) { return ( @@ -1072,11 +1084,109 @@ function SessionRowView(props: { )} + + {(row) => ( + + )} + ) } +function TurnTokenUsage(props: { + messageIDs: string[] + previousCacheRead?: number + message: (messageID: string) => SessionMessageInfo | undefined +}) { + const config = useConfig() + const { themeV2 } = useTheme() + const steps = createMemo(() => { + let previousCacheRead = props.previousCacheRead + return props.messageIDs.flatMap((messageID) => { + const message = props.message(messageID) + if (message?.type !== "assistant" || !message.tokens) return [] + const total = + message.tokens.input + + message.tokens.output + + message.tokens.reasoning + + message.tokens.cache.read + + message.tokens.cache.write + if (total === 0) return [] + const newTokens = total - message.tokens.cache.read + const cacheBust = + previousCacheRead !== undefined && message.tokens.cache.read < previousCacheRead + ? previousCacheRead - message.tokens.cache.read + : undefined + previousCacheRead = message.tokens.cache.read + return [ + { + finish: message.finish === "tool-calls" ? "tool-call" : (message.finish ?? "unknown"), + newTokens, + cached: message.tokens.cache.read, + total, + cacheBust, + }, + ] + }) + }) + const columns = createMemo(() => ({ + step: Math.max("Step".length, ...steps().map((item) => item.finish.length)), + newTokens: Math.max("New".length, ...steps().map((item) => item.newTokens.toLocaleString().length)), + cached: Math.max("Cached".length, ...steps().map((item) => item.cached.toLocaleString().length)), + total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)), + })) + return ( + 0}> + + + + ◈ + + + Tokens + + + + + {"Step".padEnd(columns().step + 2)} + {"New".padStart(columns().newTokens)} + {" "} + {"Cached".padStart(columns().cached)} + {" "} + {"Total".padStart(columns().total)} + + + + {(item) => ( + + + {item.finish.padEnd(columns().step + 2)} + + {item.newTokens.toLocaleString().padStart(columns().newTokens)} + + {" "} + {item.cached.toLocaleString().padStart(columns().cached)} + {" "} + {item.total.toLocaleString().padStart(columns().total)} + + + + ! Cache bust: {item.cacheBust?.toLocaleString()} fewer cached tokens than the previous step + + + + )} + + + + ) +} + function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { const { themeV2 } = useTheme() const shortcut = Keymap.useShortcut("session.background") diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index dc4a681bb9d..837725faf82 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -27,6 +27,7 @@ export type SessionRow = completed: boolean } | { type: "assistant-footer"; messageID: string } + | { type: "turn-usage"; messageIDs: string[]; previousCacheRead?: number } export function createSessionRows(sessionID: Accessor) { const data = useData() @@ -127,6 +128,26 @@ export function createSessionRows(sessionID: Accessor) { ), ) + createEffect( + on( + () => + data.session.message.list(sessionID()).flatMap((message) => + message.type === "assistant" + ? [ + { + id: message.id, + finish: message.finish, + error: message.error, + retry: message.retry, + tokens: message.tokens, + }, + ] + : [], + ), + () => setRows(reconcile(reduce())), + ), + ) + const appendMessage = (messageID: string) => setRows( produce((draft) => { @@ -260,6 +281,10 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S const isInput = (message: SessionMessageInfo) => inputs.has(message.id) const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) + const steps: string[] = [] + let previousCacheRead: number | undefined + let turnPreviousCacheRead: number | undefined + let measured = false return [ ...messages.filter((message) => !pending.has(message.id)), ...pendingCompactions, @@ -271,20 +296,42 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S rows.push({ type: "message", messageID: message.id }) return rows } + if (steps.length === 0) turnPreviousCacheRead = previousCacheRead + steps.push(message.id) + if (message.tokens && tokenTotal(message.tokens) > 0) { + previousCacheRead = message.tokens.cache.read + measured = true + } const ordinals = { text: 0, reasoning: 0 } message.content.forEach((part) => { const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}` if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return append(rows, { messageID: message.id, partID }, part) }) - if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) { + const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error + if (terminal || message.retry) { completePrevious(rows) rows.push({ type: "assistant-footer", messageID: message.id }) } + if (terminal) { + if (measured) + rows.push({ + type: "turn-usage", + messageIDs: [...steps], + ...(turnPreviousCacheRead === undefined ? {} : { previousCacheRead: turnPreviousCacheRead }), + }) + steps.length = 0 + turnPreviousCacheRead = undefined + measured = false + } return rows }, []) } +function tokenTotal(tokens: NonNullable) { + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write +} + export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) { const byID = new Map(messages.map((message) => [message.id, message])) const seen = new Set() @@ -309,6 +356,8 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map Date: Thu, 23 Jul 2026 20:44:14 +0530 Subject: [PATCH 16/30] chore(cli): upgrade acp sdk (#38316) --- bun.lock | 4 ++-- packages/cli/package.json | 2 +- packages/cli/src/acp/agent.ts | 2 -- packages/cli/src/acp/event.ts | 7 ++----- packages/cli/src/acp/service.ts | 12 +----------- packages/cli/test/acp/event-behavior.test.ts | 1 - packages/cli/test/acp/event.test.ts | 4 +--- packages/cli/test/acp/service-directory.test.ts | 4 +--- packages/cli/test/acp/service-usage.test.ts | 5 ----- 9 files changed, 8 insertions(+), 33 deletions(-) diff --git a/bun.lock b/bun.lock index 3ea6f31f5dd..72db1b9e928 100644 --- a/bun.lock +++ b/bun.lock @@ -124,7 +124,7 @@ "opencode2": "./bin/opencode2.cjs", }, "dependencies": { - "@agentclientprotocol/sdk": "0.21.0", + "@agentclientprotocol/sdk": "1.2.1", "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -1173,7 +1173,7 @@ "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], - "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="], "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index acf8ec75dd4..f848b562798 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,7 +22,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@agentclientprotocol/sdk": "0.21.0", + "@agentclientprotocol/sdk": "1.2.1", "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/plugin": "workspace:*", diff --git a/packages/cli/src/acp/agent.ts b/packages/cli/src/acp/agent.ts index cf8eb693f73..89ec88e8ca5 100644 --- a/packages/cli/src/acp/agent.ts +++ b/packages/cli/src/acp/agent.ts @@ -13,7 +13,6 @@ import { type PromptRequest, type ResumeSessionRequest, type SetSessionConfigOptionRequest, - type SetSessionModelRequest, type SetSessionModeRequest, } from "@agentclientprotocol/sdk" import type { OpenCodeClient } from "@opencode-ai/client/promise" @@ -33,7 +32,6 @@ export function create(client: OpenCodeClient, connection: AgentSideConnection) unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)), setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)), setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)), - unstable_setSessionModel: (params: SetSessionModelRequest) => run(service.setSessionModel(params)), prompt: (params: PromptRequest) => run(service.prompt(params)), cancel: (params: CancelNotification) => run(service.cancel(params)), } satisfies Agent diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index da8bfe168bf..3b2d7bc23cd 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -47,7 +47,6 @@ export async function streamTurn(input: { readonly sessionID: string readonly cwd: string readonly start: TurnStart - readonly userMessageID?: string | null readonly submit: (signal: AbortSignal) => Promise readonly control: TurnControl }): Promise { @@ -231,7 +230,7 @@ export async function streamTurn(input: { if (!started) { streamController.abort() await completed.catch(() => {}) - return response(undefined, undefined, "interrupted", true, undefined, input.userMessageID) + return response(undefined, undefined, "interrupted", true, undefined) } } const terminal = await completed @@ -246,7 +245,6 @@ export async function streamTurn(input: { terminal, control.cancelled, finish, - input.userMessageID, ) } catch (error) { streamController.abort() @@ -400,7 +398,6 @@ function response( terminal: "succeeded" | "failed" | "interrupted", cancelled: boolean, finish: SessionMessageAssistant["finish"], - messageID: string | null | undefined, ): PromptResponse { const error = assistant?.error ?? executionError if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError() @@ -423,7 +420,7 @@ function response( } : undefined const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type }) - return { stopReason, ...(usage ? { usage } : {}), ...(messageID ? { userMessageId: messageID } : {}), _meta: {} } + return { stopReason, ...(usage ? { usage } : {}), _meta: {} } } function resolveStopReason(input: { diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index f20f31c9bca..d9bf0ddc5b6 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -33,8 +33,6 @@ import type { ResumeSessionResponse, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, - SetSessionModelRequest, - SetSessionModelResponse, SetSessionModeRequest, SetSessionModeResponse, } from "@agentclientprotocol/sdk" @@ -88,7 +86,6 @@ export interface Interface { forkSession(input: ForkSessionRequest): Promise setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise setSessionMode(input: SetSessionModeRequest): Promise - setSessionModel(input: SetSessionModelRequest): Promise prompt(input: PromptRequest): Promise cancel(input: CancelNotification): Promise } @@ -270,13 +267,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti await selectMode(input.client, await requireSession(params.sessionId), params.modeId) return {} }, - setSessionModel: async (params) => { - const state = await requireSession(params.sessionId) - const selected = requireModel(state.catalog, params.modelId) - state.model = selected - await input.client.session.switchModel({ sessionID: state.id, model: selected }) - return {} - }, prompt: async (params) => { const state = await requireSession(params.sessionId) if (active.has(state.id)) { @@ -295,7 +285,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti sessionID: state.id, cwd: state.cwd, start: prepared.start, - userMessageID: params.messageId, control, submit: (signal) => submitPrompt(input.client, state, prepared, signal), }).finally(() => { @@ -479,6 +468,7 @@ async function registerMcpServers( function mcpConfig(server: McpServer) { if ("type" in server) { + if (server.type === "acp") throw new Error("MCP-over-ACP is not supported") return { type: "remote" as const, url: server.url, diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts index 1f9935f5c8e..46ecc552a44 100644 --- a/packages/cli/test/acp/event-behavior.test.ts +++ b/packages/cli/test/acp/event-behavior.test.ts @@ -566,7 +566,6 @@ function turn(input: { sessionID: input.sessionID, cwd: "/workspace", start: { type: "input", id: input.inputID }, - userMessageID: `client_${input.inputID}`, control: { cancelled: false, admission: new AbortController() }, submit: (signal) => input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }), diff --git a/packages/cli/test/acp/event.test.ts b/packages/cli/test/acp/event.test.ts index e6d8db9deb9..0075f5c913b 100644 --- a/packages/cli/test/acp/event.test.ts +++ b/packages/cli/test/acp/event.test.ts @@ -85,7 +85,6 @@ test("acp prompt resolves after ordered turn updates", async () => { try { const id = "msg_prompt" - const userMessageID = "client-message" const response = await streamTurn({ client, connection: { @@ -97,7 +96,6 @@ test("acp prompt resolves after ordered turn updates", async () => { sessionID: "ses_test", cwd: "/workspace", start: { type: "input", id }, - userMessageID, control: { cancelled: false, admission: new AbortController() }, submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }), }) @@ -112,7 +110,7 @@ test("acp prompt resolves after ordered turn updates", async () => { }, }, ]) - expect(response).toMatchObject({ stopReason: "end_turn", userMessageId: userMessageID, usage: { totalTokens: 2 } }) + expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } }) } finally { events?.close() await server.stop(true) diff --git a/packages/cli/test/acp/service-directory.test.ts b/packages/cli/test/acp/service-directory.test.ts index 96906223721..a3671753c92 100644 --- a/packages/cli/test/acp/service-directory.test.ts +++ b/packages/cli/test/acp/service-directory.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk" -import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture" +import { makeACPFixture, makeSession, secondModel } from "./service-fixture" describe("acp service directory behavior", () => { test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => { @@ -134,7 +134,6 @@ describe("acp service directory behavior", () => { configId: "mode", value: "plan", }) - await fixture.service.setSessionModel({ sessionId: session.sessionId, modelId: "test/test-model/high" }) await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" }) expect(currentValue(selectedModel, "model")).toBe("test/second-model") @@ -148,7 +147,6 @@ describe("acp service directory behavior", () => { ).toEqual([ { model: { providerID: "test", id: secondModel.id } }, { model: { providerID: "test", id: secondModel.id, variant: "medium" } }, - { model: { providerID: "test", id: testModel.id, variant: "high" } }, ]) expect( fixture.requests diff --git a/packages/cli/test/acp/service-usage.test.ts b/packages/cli/test/acp/service-usage.test.ts index 1ad9eee7b77..e7abb74073c 100644 --- a/packages/cli/test/acp/service-usage.test.ts +++ b/packages/cli/test/acp/service-usage.test.ts @@ -45,17 +45,14 @@ describe("acp service prompt routing and usage", () => { const commandResult = await fixture.service.prompt({ sessionId: session.sessionId, - messageId: "client-command", prompt: [{ type: "text", text: "/review now" }], }) const skillResult = await fixture.service.prompt({ sessionId: session.sessionId, - messageId: "client-skill", prompt: [{ type: "text", text: "/verify" }], }) const compactResult = await fixture.service.prompt({ sessionId: session.sessionId, - messageId: "client-compact", prompt: [{ type: "text", text: "/compact" }], }) @@ -154,13 +151,11 @@ describe("acp service prompt routing and usage", () => { const response = await fixture.service.prompt({ sessionId: session.sessionId, - messageId: "client-message", prompt: [{ type: "text", text: "hello" }], }) expect(response).toEqual({ stopReason: "end_turn", - userMessageId: "client-message", usage: { inputTokens: 100, outputTokens: 40, From 833dd2ed7f9dc9845528997c378a1fb50bc423df Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 23 Jul 2026 11:22:00 -0400 Subject: [PATCH 17/30] refactor(tui): simplify turn usage reduction (#38514) --- packages/tui/src/routes/session/index.tsx | 10 --- packages/tui/src/routes/session/rows.ts | 76 ++++++++++++----------- packages/tui/test/cli/tui/data.test.tsx | 18 ++++-- 3 files changed, 51 insertions(+), 53 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index e07947ba1c4..9b03a404225 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1036,16 +1036,6 @@ type SessionRowViewProps = { } function SessionRowView(props: SessionRowViewProps) { - const config = useConfig() - const hidden = () => props.row.type === "turn-usage" && config.data.debug?.turn_tokens !== true - return ( - - - - ) -} - -function SessionRowContent(props: SessionRowViewProps) { return ( diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 837725faf82..c6b0d744c91 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -1,6 +1,7 @@ import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" import { createEffect, on, onCleanup, type Accessor } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" +import { useConfig } from "../../config" import { useData } from "../../context/data" import { useClient } from "../../context/client" @@ -32,14 +33,20 @@ export type SessionRow = export function createSessionRows(sessionID: Accessor) { const data = useData() const client = useClient() + const config = useConfig() const [rows, setRows] = createStore([]) const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID + const turnTokens = () => config.data.debug?.turn_tokens === true function reduce() { const messages = data.session.message.list(sessionID()) const inputs = new Set(data.session.input.list(sessionID())) const boundary = revertBoundary() - const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs) + const rows = reduceSessionRows( + boundary ? messages.filter((message) => message.id < boundary) : messages, + inputs, + turnTokens(), + ) partitionPending(rows, pendingPermissions()) const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID)) rows.splice( @@ -129,23 +136,7 @@ export function createSessionRows(sessionID: Accessor) { ) createEffect( - on( - () => - data.session.message.list(sessionID()).flatMap((message) => - message.type === "assistant" - ? [ - { - id: message.id, - finish: message.finish, - error: message.error, - retry: message.retry, - tokens: message.tokens, - }, - ] - : [], - ), - () => setRows(reconcile(reduce())), - ), + on(turnTokens, () => setRows(reconcile(reduce()))), ) const appendMessage = (messageID: string) => @@ -267,9 +258,12 @@ export function createSessionRows(sessionID: Accessor) { data.on("session.step.ended", (event) => { if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return appendFooter(event.data.assistantMessageID) + if (turnTokens()) setRows(reconcile(reduce())) }), data.on("session.step.failed", (event) => { - if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID) + if (event.data.sessionID !== sessionID()) return + appendFooter(event.data.assistantMessageID) + if (turnTokens()) setRows(reconcile(reduce())) }), ] onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe())) @@ -277,14 +271,17 @@ export function createSessionRows(sessionID: Accessor) { return rows } -export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set()) { +export function reduceSessionRows( + messages: SessionMessageInfo[], + inputs = new Set(), + turnTokens = false, +) { const isInput = (message: SessionMessageInfo) => inputs.has(message.id) const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) - const steps: string[] = [] - let previousCacheRead: number | undefined - let turnPreviousCacheRead: number | undefined - let measured = false + const usage = turnTokens + ? { steps: [] as SessionMessageAssistant[], previousTurnCacheRead: undefined as number | undefined } + : undefined return [ ...messages.filter((message) => !pending.has(message.id)), ...pendingCompactions, @@ -296,12 +293,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S rows.push({ type: "message", messageID: message.id }) return rows } - if (steps.length === 0) turnPreviousCacheRead = previousCacheRead - steps.push(message.id) - if (message.tokens && tokenTotal(message.tokens) > 0) { - previousCacheRead = message.tokens.cache.read - measured = true - } + usage?.steps.push(message) const ordinals = { text: 0, reasoning: 0 } message.content.forEach((part) => { const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}` @@ -313,21 +305,31 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S completePrevious(rows) rows.push({ type: "assistant-footer", messageID: message.id }) } - if (terminal) { - if (measured) + if (terminal && usage) { + const stepsWithUsage = usage.steps.filter(hasTokenUsage) + const last = stepsWithUsage.at(-1) + if (last) { rows.push({ type: "turn-usage", - messageIDs: [...steps], - ...(turnPreviousCacheRead === undefined ? {} : { previousCacheRead: turnPreviousCacheRead }), + messageIDs: stepsWithUsage.map((step) => step.id), + ...(usage.previousTurnCacheRead === undefined + ? {} + : { previousCacheRead: usage.previousTurnCacheRead }), }) - steps.length = 0 - turnPreviousCacheRead = undefined - measured = false + usage.previousTurnCacheRead = last.tokens.cache.read + } + usage.steps.length = 0 } return rows }, []) } +function hasTokenUsage( + message: SessionMessageAssistant, +): message is SessionMessageAssistant & { tokens: NonNullable } { + return message.tokens !== undefined && tokenTotal(message.tokens) > 0 +} + function tokenTotal(tokens: NonNullable) { return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 88b2175f261..e7e6146f4b5 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -5,12 +5,14 @@ import type { OpenCodeEvent } from "@opencode-ai/client" import { SessionMessage } from "@opencode-ai/core/session/message" import { EventV2 } from "@opencode-ai/core/event" import { createEffect, onMount, type ParentProps } from "solid-js" +import { ConfigProvider } from "../../../src/config" import { ClientProvider, useClient } from "../../../src/context/client" import { DataProvider as DataProviderBase, useData } from "../../../src/context/data" import { LocationProvider, useLocation } from "../../../src/context/location" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" const formFields = [{ key: "authorization", type: "external", url: "https://example.com" }] satisfies [ { @@ -32,14 +34,18 @@ function emitEvent(events: ReturnType, event: OpenCode events.emit({ ...event, location: { directory } }) } +const config = createTuiResolvedConfig() + function DataProvider(props: ParentProps) { return ( - - - - {props.children} - - + + + + + {props.children} + + + ) } From 466b75b19d8deea761593c207d398f618ec710da Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Thu, 23 Jul 2026 21:13:46 +0530 Subject: [PATCH 18/30] feat(cli): expand acp v1 support (#38325) --- packages/cli/src/acp/agent.ts | 2 ++ packages/cli/src/acp/event.ts | 2 ++ packages/cli/src/acp/permission.ts | 3 ++- packages/cli/src/acp/service.ts | 19 +++++++++++-- packages/cli/test/acp/event-behavior.test.ts | 3 +++ packages/cli/test/acp/event.test.ts | 1 + .../acp/initialize-auth.subprocess.test.ts | 1 + .../cli/test/acp/lifecycle.subprocess.test.ts | 15 +++++++++++ .../cli/test/acp/permission-behavior.test.ts | 23 ++++++++++++++++ .../cli/test/acp/service-lifecycle.test.ts | 27 +++++++++++++++++++ 10 files changed, 93 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/acp/agent.ts b/packages/cli/src/acp/agent.ts index 89ec88e8ca5..c01ec940186 100644 --- a/packages/cli/src/acp/agent.ts +++ b/packages/cli/src/acp/agent.ts @@ -5,6 +5,7 @@ import { type AuthenticateRequest, type CancelNotification, type CloseSessionRequest, + type DeleteSessionRequest, type ForkSessionRequest, type InitializeRequest, type ListSessionsRequest, @@ -27,6 +28,7 @@ export function create(client: OpenCodeClient, connection: AgentSideConnection) newSession: (params: NewSessionRequest) => run(service.newSession(params)), loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)), listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)), + deleteSession: (params: DeleteSessionRequest) => run(service.deleteSession(params)), resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)), closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)), unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)), diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index 3b2d7bc23cd..15513538300 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -47,6 +47,7 @@ export async function streamTurn(input: { readonly sessionID: string readonly cwd: string readonly start: TurnStart + readonly writeTextFile: boolean readonly submit: (signal: AbortSignal) => Promise readonly control: TurnControl }): Promise { @@ -169,6 +170,7 @@ export async function streamTurn(input: { tools.delete(event.data.callID) await syncEditedFiles({ connection: input.connection, + writeTextFile: input.writeTextFile, sessionID: input.sessionID, cwd: input.cwd, toolName: current.name, diff --git a/packages/cli/src/acp/permission.ts b/packages/cli/src/acp/permission.ts index 71086bf9095..3c1d92957e4 100644 --- a/packages/cli/src/acp/permission.ts +++ b/packages/cli/src/acp/permission.ts @@ -53,13 +53,14 @@ export async function replyPermission(input: { export async function syncEditedFiles(input: { readonly connection: Partial> + readonly writeTextFile: boolean readonly sessionID: string readonly cwd: string readonly toolName: string readonly toolInput: ToolInput readonly structured: Readonly> }) { - if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return + if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return const files = Array.isArray(input.structured.files) ? input.structured.files.flatMap((file): string[] => { if (!file || typeof file !== "object") return [] diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index d9bf0ddc5b6..963b5c3ed47 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -16,6 +16,8 @@ import type { CancelNotification, CloseSessionRequest, CloseSessionResponse, + DeleteSessionRequest, + DeleteSessionResponse, ForkSessionRequest, ForkSessionResponse, InitializeRequest, @@ -45,7 +47,8 @@ import { ACPError } from "./error" export const AuthMethodID = "opencode-login" -type Connection = Pick +type Connection = Pick & + Partial> type Catalog = { readonly providers: ConfigOptionProvider[] @@ -81,6 +84,7 @@ export interface Interface { newSession(input: NewSessionRequest): Promise loadSession(input: LoadSessionRequest): Promise listSessions(input: ListSessionsRequest): Promise + deleteSession(input: DeleteSessionRequest): Promise resumeSession(input: ResumeSessionRequest): Promise closeSession(input: CloseSessionRequest): Promise forkSession(input: ForkSessionRequest): Promise @@ -95,6 +99,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti const catalogs = new Map>() const registeredMcp = new Map>() const active = new Map() + const capabilities = { writeTextFile: false } const catalog = (cwd: string) => { const cached = catalogs.get(cwd) @@ -154,6 +159,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti return { initialize: async (params) => { + capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", name: "Login with opencode", @@ -170,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti loadSession: true, mcpCapabilities: { http: true, sse: false }, promptCapabilities: { embeddedContext: true, image: true }, - sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} }, + sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} }, }, authMethods: [authMethod], agentInfo: { name: "OpenCode", version: OPENCODE_VERSION }, @@ -213,6 +219,14 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti ...(page.cursor.next ? { nextCursor: page.cursor.next } : {}), } }, + deleteSession: async (params) => { + await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => { + if (!isSessionNotFoundError(error)) throw error + }) + sessions.delete(params.sessionId) + registeredMcp.delete(params.sessionId) + return {} + }, resumeSession: async (params) => { const session = await getSession(input.client, params.sessionId) const state = await attach(session, session.location.directory, params.mcpServers ?? []) @@ -285,6 +299,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti sessionID: state.id, cwd: state.cwd, start: prepared.start, + writeTextFile: capabilities.writeTextFile, control, submit: (signal) => submitPrompt(input.client, state, prepared, signal), }).finally(() => { diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts index 46ecc552a44..ded7b7020aa 100644 --- a/packages/cli/test/acp/event-behavior.test.ts +++ b/packages/cli/test/acp/event-behavior.test.ts @@ -439,6 +439,7 @@ describe("acp event behavior", () => { sessionID: "ses_cancel", cwd: "/workspace", start: { type: "input", id: "input_cancel" }, + writeTextFile: false, control, submit: async (signal) => { await fixture.client.session.prompt( @@ -481,6 +482,7 @@ describe("acp event behavior", () => { sessionID: "ses_cancel_admission", cwd: "/workspace", start: { type: "input", id: "input_cancel_admission" }, + writeTextFile: false, control, submit: (signal) => fixture.client.session.prompt( @@ -566,6 +568,7 @@ function turn(input: { sessionID: input.sessionID, cwd: "/workspace", start: { type: "input", id: input.inputID }, + writeTextFile: false, control: { cancelled: false, admission: new AbortController() }, submit: (signal) => input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }), diff --git a/packages/cli/test/acp/event.test.ts b/packages/cli/test/acp/event.test.ts index 0075f5c913b..575705b294e 100644 --- a/packages/cli/test/acp/event.test.ts +++ b/packages/cli/test/acp/event.test.ts @@ -96,6 +96,7 @@ test("acp prompt resolves after ordered turn updates", async () => { sessionID: "ses_test", cwd: "/workspace", start: { type: "input", id }, + writeTextFile: false, control: { cancelled: false, admission: new AbortController() }, submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }), }) diff --git a/packages/cli/test/acp/initialize-auth.subprocess.test.ts b/packages/cli/test/acp/initialize-auth.subprocess.test.ts index 2303b5b3a13..904ceadd5a3 100644 --- a/packages/cli/test/acp/initialize-auth.subprocess.test.ts +++ b/packages/cli/test/acp/initialize-auth.subprocess.test.ts @@ -14,6 +14,7 @@ describe("acp initialize/auth subprocess", () => { expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false) expect(initialized.agentCapabilities?.loadSession).toBe(true) expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) diff --git a/packages/cli/test/acp/lifecycle.subprocess.test.ts b/packages/cli/test/acp/lifecycle.subprocess.test.ts index ffd0332620e..4ae855a7f38 100644 --- a/packages/cli/test/acp/lifecycle.subprocess.test.ts +++ b/packages/cli/test/acp/lifecycle.subprocess.test.ts @@ -1,5 +1,6 @@ import type { CloseSessionResponse, + DeleteSessionResponse, ListSessionsResponse, LoadSessionResponse, ResumeSessionResponse, @@ -60,6 +61,20 @@ describe("acp lifecycle subprocess", () => { expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true) }, 60_000) + test("delete capability and delete request", async () => { + await using fixture = await createAcpFixture() + const acp = fixture.spawn() + const initialized = await initialize(acp) + expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({}) + const session = await newSession(acp, fixture.home) + + expect( + expectOk(await acp.request("session/delete", { sessionId: session.sessionId })), + ).toEqual({}) + const listed = expectOk(await acp.request("session/list", { cwd: fixture.home })) + expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false) + }, 60_000) + test("resume capability advertisement", async () => { await using fixture = await createAcpFixture() const initialized = await initialize(fixture.spawn()) diff --git a/packages/cli/test/acp/permission-behavior.test.ts b/packages/cli/test/acp/permission-behavior.test.ts index ad8ccd128a2..355ff5bb410 100644 --- a/packages/cli/test/acp/permission-behavior.test.ts +++ b/packages/cli/test/acp/permission-behavior.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { streamTurn } from "../../src/acp/event" +import { syncEditedFiles } from "../../src/acp/permission" import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture" type SessionUpdateParams = Parameters[0] @@ -12,6 +13,27 @@ type Connection = Pick describe("acp permission behavior", () => { + test("does not sync edits when writeTextFile was not advertised", async () => { + const writes: Parameters[0][] = [] + + await syncEditedFiles({ + connection: { + writeTextFile: async (input) => { + writes.push(input) + return {} + }, + }, + writeTextFile: false, + sessionID: "ses_no_write", + cwd: "/workspace", + toolName: "edit", + toolInput: { filePath: "/workspace/file.ts" }, + structured: {}, + }) + + expect(writes).toEqual([]) + }) + test("forwards allow-once and allow-always selections to the generated client", async () => { const permissionRequests: RequestPermissionRequest[] = [] const fixture = createSseFixture({ @@ -465,6 +487,7 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string, sessionID, cwd, start: { type: "input", id: inputID }, + writeTextFile: true, control: { cancelled: false, admission: new AbortController() }, submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }), }) diff --git a/packages/cli/test/acp/service-lifecycle.test.ts b/packages/cli/test/acp/service-lifecycle.test.ts index 2c4992b8e5b..35a46a0ee2f 100644 --- a/packages/cli/test/acp/service-lifecycle.test.ts +++ b/packages/cli/test/acp/service-lifecycle.test.ts @@ -225,6 +225,33 @@ describe("acp service lifecycle", () => { "/api/session/missing/interrupt", ]) }) + + test("deletes sessions from backing and local storage", async () => { + await using fixture = makeACPFixture({ + fetch(request) { + if (request.method === "POST" && request.path === "/api/session") { + return Response.json({ data: makeSession("ses_delete") }) + } + if (request.method === "DELETE" && request.path === "/api/session/ses_delete") { + return new Response(null, { status: 204 }) + } + return undefined + }, + }) + const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }) + + expect(await fixture.service.deleteSession({ sessionId: session.sessionId })).toEqual({}) + expect(fixture.requests).toContainEqual({ + method: "DELETE", + path: "/api/session/ses_delete", + query: {}, + body: undefined, + }) + const missing = await fixture.service + .setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" }) + .catch((error: unknown) => error) + expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId }) + }) }) function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) { From 8f3465c951a024028a92adc9a283038a085f967f Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 23 Jul 2026 12:35:29 -0400 Subject: [PATCH 19/30] refactor(tui): load native V2 themes (#38430) --- packages/docs/script/generate-theme-tokens.ts | 6 +- packages/tui/src/app.tsx | 2 + .../tui/src/component/theme-error-toast.tsx | 20 ++++ packages/tui/src/context/theme.tsx | 92 ++++++++++++------ packages/tui/src/mini/theme.ts | 6 +- packages/tui/src/theme/index.ts | 74 +++++++++++---- packages/tui/src/theme/resolve.ts | 8 +- packages/tui/src/theme/v1.ts | 4 +- packages/tui/src/theme/v2/defaults.ts | 4 +- packages/tui/src/theme/v2/index.ts | 2 +- packages/tui/src/theme/v2/resolve.ts | 20 +--- packages/tui/src/theme/v2/schema.ts | 4 +- packages/tui/src/theme/v2/select.ts | 32 +++---- packages/tui/src/theme/v2/v1-migrate.ts | 8 +- packages/tui/test/cli/tui/theme-mode.test.tsx | 87 ++++++++++++++++- packages/tui/test/theme.test.ts | 94 ++++++++++++++++++- packages/tui/test/theme/v2/resolve.test.ts | 56 ++++++----- packages/tui/test/theme/v2/select.test.ts | 28 +++--- packages/tui/test/theme/v2/types.test.ts | 14 +-- packages/tui/test/theme/v2/v1-migrate.test.ts | 10 +- 20 files changed, 410 insertions(+), 161 deletions(-) create mode 100644 packages/tui/src/component/theme-error-toast.tsx diff --git a/packages/docs/script/generate-theme-tokens.ts b/packages/docs/script/generate-theme-tokens.ts index 0c441075f96..25f614d119b 100644 --- a/packages/docs/script/generate-theme-tokens.ts +++ b/packages/docs/script/generate-theme-tokens.ts @@ -2,7 +2,7 @@ import { Schema, SchemaAST } from "effect" import { format } from "prettier" -import { ThemeDefinition, ThemeFile } from "../../tui/src/theme/v2/schema" +import { ThemeDefinition, ThemeDocument } from "../../tui/src/theme/v2/schema" const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx" const root = requireObject(ThemeDefinition.ast) @@ -52,8 +52,8 @@ const example = { default: "#101014", }, }, -} satisfies ThemeFile -Schema.decodeUnknownSync(ThemeFile)(example) +} satisfies ThemeDocument +Schema.decodeUnknownSync(ThemeDocument)(example) const output = await format( `{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */} diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 5e600b65e84..91b286e7986 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -65,6 +65,7 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" +import { ThemeErrorToast } from "./component/theme-error-toast" import { ThemeProvider, useTheme } from "./context/theme" import { Home } from "./routes/home" import { Session } from "./routes/session" @@ -337,6 +338,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { + diff --git a/packages/tui/src/component/theme-error-toast.tsx b/packages/tui/src/component/theme-error-toast.tsx new file mode 100644 index 00000000000..f10a11bdc94 --- /dev/null +++ b/packages/tui/src/component/theme-error-toast.tsx @@ -0,0 +1,20 @@ +import { onCleanup } from "solid-js" +import { useTheme } from "../context/theme" +import { useToast } from "../ui/toast" + +export function ThemeErrorToast() { + const theme = useTheme() + const toast = useToast() + + onCleanup( + theme.onError(({ name, error }) => + toast.show({ + variant: "error", + title: `Failed to load theme: ${name}`, + message: error.message, + }), + ), + ) + + return null +} diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 16ffdeaccbe..9b53e8d07a2 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -5,21 +5,20 @@ import { addTheme, allThemes, hasTheme, - isTheme, + parseTheme, selectedForeground, setCustomThemes, setSystemTheme, subscribeThemes, upsertTheme, type Theme, - type ThemeJson, + type ThemeDocumentSource, } from "../theme" import { generateSyntax } from "../theme/v2/syntax" import { generateSystem, terminalMode } from "../theme/system" import { discoverThemes, themeDirectories } from "../theme/discovery" import { createComponentTheme, type ComponentTheme } from "../theme/v2/component" -import { resolveThemeFile } from "../theme/v2/resolve" -import { migrateV1 } from "../theme/v2/v1-migrate" +import { resolveThemeDocument } from "../theme/v2/resolve" import { themeModes } from "../theme/v2/select" import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js" import { createStore, produce } from "solid-js/store" @@ -29,6 +28,36 @@ import { Global } from "@opencode-ai/util/global" import { DevTools } from "../devtools" const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" }) +export type ThemeError = { name: string; error: Error } +type ThemeErrorHandler = (event: ThemeError) => void + +function createThemeErrors() { + let handler: ThemeErrorHandler | undefined + let pending: ThemeError | undefined + + return { + emit(name: string, cause: unknown) { + const event = { name, error: cause instanceof Error ? cause : new Error(String(cause)) } + if (handler) { + handler(event) + return + } + pending = event + }, + onError(next: ThemeErrorHandler) { + handler = next + if (pending) { + next(pending) + pending = undefined + } + return () => { + if (handler === next) handler = undefined + } + }, + } +} + +const themeErrors = createThemeErrors() export type ThemeSource = Readonly<{ discover(): Promise> @@ -61,7 +90,7 @@ export { const THEME_REFRESH_DELAYS = [250, 1000] as const type State = { - themes: Record + themes: Record mode: "dark" | "light" lock: "dark" | "light" | undefined active: string @@ -84,6 +113,7 @@ type ThemeService = { unlock(): void setMode(mode?: "dark" | "light", persist?: boolean): boolean set(theme: string): boolean + onError(handler: ThemeErrorHandler): () => void readonly ready: boolean } @@ -139,12 +169,7 @@ const themeContext = createSimpleContext({ return themes .discover() .then((themes) => { - setCustomThemes( - Object.entries(themes).reduce>((result, [name, theme]) => { - if (isTheme(theme)) result[name] = theme - return result - }, {}), - ) + setCustomThemes(themes) }) .catch(() => setStore("active", "opencode")) } @@ -269,30 +294,26 @@ const themeContext = createSimpleContext({ }) const initStarted = performance.now() - const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode) - const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode")) - const file = createMemo(() => migrateV1(source())) - const modes = createMemo(() => themeModes(file())) - const mode = () => { - const supported = modes() - if (supported.includes(store.mode)) return store.mode - return supported[0] ?? store.mode - } - const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName())) + const selected = createMemo(() => { + const name = store.themes[store.active] ? store.active : "opencode" + try { + return loadTheme(store.themes[name], name, store.mode) + } catch (error) { + if (name === "opencode") throw error + themeErrors.emit(name, error) + setStore("active", "opencode") + return loadTheme(store.themes.opencode, "opencode", store.mode) + } + }) + const modes = () => selected().modes + const mode = () => selected().mode + const valuesV2 = () => selected().theme valuesV2() themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`) const themeV2 = createComponentTheme(valuesV2, mode) const contextsV2 = { - elevated: createComponentTheme(() => { - const theme = valuesV2().contexts["@context:elevated"] - if (!theme) throw new Error("Theme context is not defined: elevated") - return theme - }, mode), - overlay: createComponentTheme(() => { - const theme = valuesV2().contexts["@context:overlay"] - if (!theme) throw new Error("Theme context is not defined: overlay") - return theme - }, mode), + elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode), + overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode), } createEffect(() => renderer.setBackgroundColor(valuesV2().background.default)) @@ -331,6 +352,7 @@ const themeContext = createSimpleContext({ .catch(() => {}) return true }, + onError: themeErrors.onError, get ready() { return store.ready }, @@ -354,6 +376,14 @@ export function ThemeContextProvider(props: ParentProps<{ context: ContextName } ) } + +function loadTheme(source: ThemeDocumentSource, name: string, requested: "dark" | "light") { + const document = parseTheme(source, name) + const modes = themeModes(document) + const mode = modes.includes(requested) ? requested : (modes[0] ?? requested) + return { modes, mode, theme: resolveThemeDocument(document, mode) } +} + export function createSyntaxStyleMemo(factory: () => SyntaxStyle) { const renderer = useRenderer() const retained = new Set() diff --git a/packages/tui/src/mini/theme.ts b/packages/tui/src/mini/theme.ts index 736eed08bb2..3c5afed16e6 100644 --- a/packages/tui/src/mini/theme.ts +++ b/packages/tui/src/mini/theme.ts @@ -10,7 +10,7 @@ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" import { ansiToRgba } from "../theme/color" import { resolveThemeColors } from "../theme/resolve" import { terminalMode } from "../theme/system" -import type { ThemeJson } from "../theme/v1" +import type { ThemeV1Json } from "../theme/v1" import type { EntryKind, RunTuiConfig } from "./types" type Tone = { @@ -184,7 +184,7 @@ function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number) return nearestIndexed(indexed, mixed) } -export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent { +export function resolveTheme(theme: ThemeV1Json, pick: "dark" | "light"): TuiThemeCurrent { const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code))) return { ...resolved.theme, @@ -246,7 +246,7 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => return map(RGBA.fromInts(gray, gray, gray)) } -export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeJson { +export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeV1Json { const bg_snapshot = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const fg_snapshot = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) const bg = RGBA.defaultBackground(bg_snapshot) diff --git a/packages/tui/src/theme/index.ts b/packages/tui/src/theme/index.ts index ef522b43c28..d128a75539f 100644 --- a/packages/tui/src/theme/index.ts +++ b/packages/tui/src/theme/index.ts @@ -1,16 +1,25 @@ +import { Schema } from "effect" import { resolveThemeColors } from "./resolve" -import { DEFAULT_THEMES, type Theme, type ThemeJson } from "./v1" +import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1" +import { resolveThemeDocument, themeDecodeError } from "./v2/resolve" +import { ThemeDocument } from "./v2/schema" +import { migrateV1 } from "./v2/v1-migrate" -export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeJson } from "./v1" +export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1" +export { resolveThemeDocument, type ThemeDocument } -const pluginThemes: Record = {} -let customThemes: Record = {} -let systemTheme: ThemeJson | undefined -const listeners = new Set<(themes: Record) => void>() +export type ThemeDocumentSource = Record + +const pluginThemes: Record = {} +let customThemes: Record = {} +let systemTheme: ThemeDocumentSource | undefined +const listeners = new Set<(themes: Record) => void>() +const parsed = new WeakMap() +const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument) function listThemes() { // Priority: defaults < plugin installs < custom files < generated system. - const themes = { + const themes: Record = { ...DEFAULT_THEMES, ...pluginThemes, ...customThemes, @@ -31,23 +40,40 @@ export function allThemes() { return listThemes() } -export function isTheme(theme: unknown): theme is ThemeJson { - if (typeof theme !== "object" || theme === null || Array.isArray(theme)) return false - const value = Reflect.get(theme, "theme") - return typeof value === "object" && value !== null && !Array.isArray(value) +export function isThemeSource(source: unknown): source is ThemeDocumentSource { + if (typeof source !== "object" || source === null || Array.isArray(source)) return false + return "theme" in source || "version" in source } -export function subscribeThemes(listener: (themes: Record) => void) { +export function parseTheme(source: ThemeDocumentSource, name = "theme") { + const cached = parsed.get(source) + if (cached) return cached + + const version = source.version ?? 1 + const document = + version === 1 + ? migrateV1(source as ThemeV1Json) + : version === 2 + ? decodeV2Theme(source, name) + : unsupportedThemeVersion(version) + + parsed.set(source, document) + return document +} + +export function subscribeThemes(listener: (themes: Record) => void) { listeners.add(listener) return () => listeners.delete(listener) } -export function setCustomThemes(themes: Record) { - customThemes = themes +export function setCustomThemes(themes: Record) { + customThemes = Object.fromEntries( + Object.entries(themes).filter((entry): entry is [string, ThemeDocumentSource] => isThemeSource(entry[1])), + ) syncThemes() } -export function setSystemTheme(theme: ThemeJson | undefined) { +export function setSystemTheme(theme: ThemeDocumentSource | undefined) { systemTheme = theme syncThemes() } @@ -59,7 +85,7 @@ export function hasTheme(name: string) { export function addTheme(name: string, theme: unknown) { if (!name) return false - if (!isTheme(theme)) return false + if (!isThemeSource(theme)) return false if (hasTheme(name)) return false pluginThemes[name] = theme syncThemes() @@ -68,7 +94,7 @@ export function addTheme(name: string, theme: unknown) { export function upsertTheme(name: string, theme: unknown) { if (!name) return false - if (!isTheme(theme)) return false + if (!isThemeSource(theme)) return false if (customThemes[name] !== undefined) { customThemes[name] = theme } else { @@ -78,7 +104,7 @@ export function upsertTheme(name: string, theme: unknown) { return true } -export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme { +export function resolveTheme(theme: ThemeV1Json, mode: "dark" | "light"): Theme { const resolved = resolveThemeColors(theme, mode) return { ...resolved.theme, @@ -86,3 +112,15 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme { thinkingOpacity: resolved.thinkingOpacity, } } + +function decodeV2Theme(source: ThemeDocumentSource, name: string) { + try { + return decodeThemeDocument(source) + } catch (error) { + throw themeDecodeError(error, name) + } +} + +function unsupportedThemeVersion(version: unknown): never { + throw new Error(`Unsupported theme version: ${String(version)}`) +} diff --git a/packages/tui/src/theme/resolve.ts b/packages/tui/src/theme/resolve.ts index b8cf36866c4..6d028e8fa95 100644 --- a/packages/tui/src/theme/resolve.ts +++ b/packages/tui/src/theme/resolve.ts @@ -1,9 +1,9 @@ import { RGBA } from "@opentui/core" import { ansiToRgba } from "./color" -import type { ColorValue, Theme, ThemeColor, ThemeJson } from "./v1" +import type { ColorValue, Theme, ThemeColor, ThemeV1Json } from "./v1" export function resolveThemeColors( - theme: ThemeJson, + theme: ThemeV1Json, mode: "dark" | "light", resolveAnsi: (code: number) => RGBA = ansiToRgba, ) { @@ -43,7 +43,9 @@ export function resolveThemeColors( ? resolveColor(theme.theme.selectedListItemText!) : resolved.background!, backgroundMenu: - theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu), + theme.theme.backgroundMenu === undefined + ? resolved.backgroundElement! + : resolveColor(theme.theme.backgroundMenu), } satisfies Omit, hasSelectedListItemText, thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6, diff --git a/packages/tui/src/theme/v1.ts b/packages/tui/src/theme/v1.ts index 483882e274a..5cf1df1b9f2 100644 --- a/packages/tui/src/theme/v1.ts +++ b/packages/tui/src/theme/v1.ts @@ -98,7 +98,7 @@ export type Variant = { light: HexColor | RefName } export type ColorValue = HexColor | RefName | Variant | RGBA | number -export type ThemeJson = { +export type ThemeV1Json = { $schema?: string defs?: Record theme: Omit, "selectedListItemText" | "backgroundMenu"> & { @@ -108,7 +108,7 @@ export type ThemeJson = { } } -export const DEFAULT_THEMES: Record = { +export const DEFAULT_THEMES: Record = { aura, ayu, catppuccin, diff --git a/packages/tui/src/theme/v2/defaults.ts b/packages/tui/src/theme/v2/defaults.ts index 666f45014cf..e9eb352a07f 100644 --- a/packages/tui/src/theme/v2/defaults.ts +++ b/packages/tui/src/theme/v2/defaults.ts @@ -1,4 +1,4 @@ -import type { HueName, ThemeFile } from "./schema" +import type { HueName, ThemeDocument } from "./schema" export const DEFAULT_CATEGORICAL = [ "blue", @@ -437,4 +437,4 @@ export const DEFAULT_THEME = { }, }, }, -} satisfies ThemeFile +} satisfies ThemeDocument diff --git a/packages/tui/src/theme/v2/index.ts b/packages/tui/src/theme/v2/index.ts index 953112da50d..b6cfbb98f52 100644 --- a/packages/tui/src/theme/v2/index.ts +++ b/packages/tui/src/theme/v2/index.ts @@ -16,7 +16,7 @@ export { SyntaxDefinition, SyntaxToken, ThemeDefinition, - ThemeFile, + ThemeDocument, type BackgroundDefinition, type DiffDefinition, type FileThemeDefinition, diff --git a/packages/tui/src/theme/v2/resolve.ts b/packages/tui/src/theme/v2/resolve.ts index 74d06e8f1ad..500f853dbd8 100644 --- a/packages/tui/src/theme/v2/resolve.ts +++ b/packages/tui/src/theme/v2/resolve.ts @@ -11,7 +11,7 @@ import { HueAlias, HueStep, ThemeDefinition, - ThemeFile, + ThemeDocument, } from "./schema" import type { ActionStateKey, @@ -26,7 +26,6 @@ import type { import { selectTheme, selectThemeMode } from "./select" const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition) -const decodeThemeFileSchema = Schema.decodeUnknownSync(ThemeFile) function decodeThemeDefinition(input: unknown) { try { @@ -36,27 +35,18 @@ function decodeThemeDefinition(input: unknown) { } } -function decodeThemeFile(input: unknown, name: string) { - try { - return decodeThemeFileSchema(input) - } catch (error) { - throw themeDecodeError(error, name) - } -} - -function themeDecodeError(error: unknown, name: string) { +export function themeDecodeError(error: unknown, name: string) { const message = Schema.isSchemaError(error) ? error.message : String(error) const value = /got ("[^"]*"|\S+)/.exec(message)?.[1] ?? "value" return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error }) } -export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name = "theme") { - const decoded = decodeThemeFile(file, name) - const selected = selectThemeMode(decoded, mode) +export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") { + const selected = selectThemeMode(document, mode) const definition = selected.expanded ? selected.theme : expandTheme(selected.theme) const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode)) const core = expandTokens(fallback()) - const merged = decoded.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition) + const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition) if (!merged["hue"]) throw new Error("Standalone themes must provide hues") return resolveExpandedTheme({ ...merged, diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/tui/src/theme/v2/schema.ts index 76a33f7a099..2256a3a1d64 100644 --- a/packages/tui/src/theme/v2/schema.ts +++ b/packages/tui/src/theme/v2/schema.ts @@ -251,8 +251,8 @@ const FileMetadata = { version: Schema.Literal(2), standalone: Schema.optional(Schema.Boolean), } -export const ThemeFile = Schema.Union([ +export const ThemeDocument = Schema.Union([ Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }), Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }), ]) -export type ThemeFile = Schema.Schema.Type +export type ThemeDocument = Schema.Schema.Type diff --git a/packages/tui/src/theme/v2/select.ts b/packages/tui/src/theme/v2/select.ts index c763179f53a..17d47488fa1 100644 --- a/packages/tui/src/theme/v2/select.ts +++ b/packages/tui/src/theme/v2/select.ts @@ -5,45 +5,45 @@ import type { Mode, ModeDefinition, ThemeDefinition, - ThemeFile, + ThemeDocument, } from "./index" export function selectTheme( - file: ThemeFile & { light: ThemeDefinition; dark: ThemeDefinition }, + document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition }, mode?: Mode, ): ThemeDefinition -export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition -export function selectTheme(file: ThemeFile, mode?: Mode) { - return selectThemeMode(file, mode).theme +export function selectTheme(document: ThemeDocument, mode?: Mode): FileThemeDefinition +export function selectTheme(document: ThemeDocument, mode?: Mode) { + return selectThemeMode(document, mode).theme } export function selectThemeMode( - file: ThemeFile, + document: ThemeDocument, mode: Mode = "light", ): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } { - const modes = themeModes(file) + const modes = themeModes(document) const selectedMode = modes.includes(mode) ? mode : modes[0] - const selected = file[selectedMode] + const selected = document[selectedMode] if (!selected) throw new Error("Theme must provide at least one mode") - if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes") + if (merges(document.light) && merges(document.dark)) throw new Error("Light and dark themes cannot both merge modes") if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false } const otherMode = selectedMode === "light" ? "dark" : "light" - const other = file[otherMode] + const other = document[otherMode] if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`) const merged = mergeTheme(expandTheme(other), expandTheme(selected)) if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`) return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true } } -export function themeModes(file: ThemeFile): readonly Mode[] { - if (merges(file.light) && !file.dark) throw new Error("The light theme cannot merge without a dark theme") - if (merges(file.dark) && !file.light) throw new Error("The dark theme cannot merge without a light theme") - return (["light", "dark"] as const).filter((mode) => file[mode] !== undefined) +export function themeModes(document: ThemeDocument): readonly Mode[] { + if (merges(document.light) && !document.dark) throw new Error("The light theme cannot merge without a dark theme") + if (merges(document.dark) && !document.light) throw new Error("The dark theme cannot merge without a light theme") + return (["light", "dark"] as const).filter((mode) => document[mode] !== undefined) } -export function supportsThemeMode(file: ThemeFile, mode: Mode) { - return themeModes(file).includes(mode) +export function supportsThemeMode(document: ThemeDocument, mode: Mode) { + return themeModes(document).includes(mode) } function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition { diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/tui/src/theme/v2/v1-migrate.ts index 42e7c31b962..4fdea0d135e 100644 --- a/packages/tui/src/theme/v2/v1-migrate.ts +++ b/packages/tui/src/theme/v2/v1-migrate.ts @@ -1,8 +1,8 @@ import { RGBA } from "@opentui/core" import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color" -import type { Theme, ThemeJson } from "../index" +import type { Theme, ThemeV1Json } from "../v1" import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults" -import type { FileThemeDefinition, Mode, ThemeFile } from "./index" +import type { FileThemeDefinition, Mode, ThemeDocument } from "./index" import { HueStep } from "./schema" type ThemeColor = Exclude @@ -14,7 +14,7 @@ const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "succes const minimumChroma = 0.03 const lightThreshold = 0.6 -export function migrateV1(theme: ThemeJson): ThemeFile { +export function migrateV1(theme: ThemeV1Json): ThemeDocument { const light = resolveV1(theme, "light") const dark = resolveV1(theme, "dark") if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) { @@ -234,7 +234,7 @@ function ambiguous(color: RGBA, chroma = toOklch(color).c) { return color.toInts()[3] === 0 || chroma < minimumChroma } -function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme { +function resolveV1(theme: ThemeV1Json, mode: "dark" | "light"): Theme { const defs = theme.defs ?? {} function resolveColor(value: unknown, chain: string[] = []): RGBA { diff --git a/packages/tui/test/cli/tui/theme-mode.test.tsx b/packages/tui/test/cli/tui/theme-mode.test.tsx index cb195e384bb..0b8123f05c7 100644 --- a/packages/tui/test/cli/tui/theme-mode.test.tsx +++ b/packages/tui/test/cli/tui/theme-mode.test.tsx @@ -1,10 +1,13 @@ /** @jsxImportSource @opentui/solid */ import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" +import { RGBA } from "@opentui/core" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { DEFAULT_THEMES } from "../../../src/theme" +import { DEFAULT_THEME } from "../../../src/theme/v2/defaults" +import { selectTheme } from "../../../src/theme/v2/select" import { ConfigProvider } from "../../../src/config" -import { ThemeProvider, useTheme } from "../../../src/context/theme" +import { ThemeProvider, useTheme, type ThemeError } from "../../../src/context/theme" async function wait(fn: () => boolean) { const started = Date.now() @@ -24,6 +27,7 @@ test("uses an available mode while retaining the pinned preference", async () => const darkOnly = structuredClone(DEFAULT_THEMES.opencode) darkOnly.theme.background = "#111111" darkOnly.theme.text = "#eeeeee" + const native = { version: 2, dark: { text: { default: "#abcdef" } } } as const let theme: ReturnType | undefined function Probe() { @@ -42,7 +46,7 @@ test("uses an available mode while retaining the pinned preference", async () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }} + source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual, native }) }} > @@ -66,6 +70,85 @@ test("uses an available mode while retaining the pinned preference", async () => expect(current().set("dual")).toBeTrue() await wait(() => current().mode() === "dark") expect(current().modes()).toEqual(["light", "dark"]) + expect(current().set("native")).toBeTrue() + await wait(() => current().selected === "native") + expect(current().modes()).toEqual(["dark"]) + expect(current().themeV2.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue() + } finally { + app.renderer.destroy() + } +}) + +test.each([ + ["schema", { version: 2, light: { categorical: [] } }], + ["mode merging", { version: 2, light: { mergeMode: true } }], + ["token reference", { version: 2, light: { text: { default: "$missing" } } }], +] as const)("falls back to OpenCode when configured V2 theme %s is invalid", async (_label, source) => { + let theme: ReturnType | undefined + let failure: ThemeError | undefined + let unsubscribe: (() => void) | undefined + + function Probe() { + const value = useTheme() + theme = value + unsubscribe = value.onError((error) => (failure = error)) + return {value.selected} + } + + const app = await testRender( + () => ( + + Promise.resolve({ invalid: source }) }}> + + + + ), + { width: 20, height: 2 }, + ) + app.renderer.start() + + try { + await wait(() => theme?.ready === true) + expect(theme?.selected).toBe("opencode") + expect(failure?.name).toBe("invalid") + expect(failure?.error).toBeInstanceOf(Error) + expect(failure?.error.message.length).toBeGreaterThan(0) + } finally { + unsubscribe?.() + app.renderer.destroy() + } +}) + +test("contextual themes fall back to a standalone theme's base view", async () => { + const standalone = { + version: 2, + standalone: true, + dark: { hue: selectTheme(DEFAULT_THEME, "dark").hue }, + } as const + let theme: ReturnType | undefined + + function Probe() { + theme = useTheme() + return {theme.selected} + } + + const app = await testRender( + () => ( + + Promise.resolve({ standalone }) }}> + + + + ), + { width: 20, height: 2 }, + ) + app.renderer.start() + + try { + await wait(() => theme?.ready === true) + if (!theme) throw new Error("Theme provider is not mounted") + expect(theme.contextual("elevated").themeV2.text.default).toBe(theme.themeV2.text.default) + expect(theme.contextual("overlay").themeV2.background.default).toBe(theme.themeV2.background.default) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/theme.test.ts b/packages/tui/test/theme.test.ts index b2208f42c49..05d13d8601e 100644 --- a/packages/tui/test/theme.test.ts +++ b/packages/tui/test/theme.test.ts @@ -2,7 +2,16 @@ import { expect, test } from "bun:test" import { mkdir, writeFile } from "node:fs/promises" import path from "node:path" import type { TerminalColors } from "@opentui/core" -import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme } from "../src/theme" +import { + DEFAULT_THEMES, + addTheme, + allThemes, + hasTheme, + parseTheme, + resolveTheme, + setCustomThemes, + upsertTheme, +} from "../src/theme" import { discoverThemes, themeDirectories } from "../src/theme/discovery" import { terminalMode } from "../src/theme/system" import { tmpdir } from "./fixture/fixture" @@ -10,7 +19,7 @@ import { tmpdir } from "./fixture/fixture" test("addTheme writes into module theme store", () => { const name = `plugin-theme-${Date.now()}` expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true) - expect(allThemes()[name]).toBeDefined() + expect(allThemes()[name]).toBe(DEFAULT_THEMES.opencode) }) test("addTheme keeps first theme for duplicate names", () => { @@ -22,15 +31,92 @@ test("addTheme keeps first theme for duplicate names", () => { expect(addTheme(name, one)).toBe(true) expect(addTheme(name, two)).toBe(false) - expect(allThemes()[name]!.theme.primary).toBe("#101010") + expect(allThemes()[name]).toBe(one) }) -test("addTheme ignores entries without a theme object", () => { +test("addTheme ignores values without a V1 theme or version", () => { const name = `plugin-theme-invalid-${Date.now()}` expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false) + expect(addTheme(name, { light: {} })).toBe(false) expect(allThemes()[name]).toBeUndefined() }) +test("addTheme defers validation of versioned sources", () => { + const name = `plugin-theme-versioned-${Date.now()}` + expect(addTheme(name, { version: 2 })).toBe(true) + expect(() => parseTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`) +}) + +test("parseTheme delegates malformed V1 sources and rejects unknown versions", () => { + expect(() => parseTheme({})).toThrow() + expect(() => parseTheme({ version: 3 })).toThrow("Unsupported theme version: 3") +}) + +test("parses unversioned and explicit V1 themes lazily once", () => { + const unversioned = structuredClone(DEFAULT_THEMES.opencode) + const explicit = { ...structuredClone(DEFAULT_THEMES.opencode), version: 1 } + const first = parseTheme(unversioned, "unversioned") + const second = parseTheme(explicit, "explicit") + + expect(first.version).toBe(2) + expect(second.version).toBe(2) + expect(parseTheme(unversioned, "unversioned")).toBe(first) + expect(parseTheme(explicit, "explicit")).toBe(second) +}) + +test("decodes native V2 themes lazily once", () => { + const name = `plugin-theme-v2-${Date.now()}` + const source = { version: 2, light: { categorical: ["red"] } } as const + + expect(addTheme(name, source)).toBe(true) + expect(allThemes()[name]).toBe(source) + const document = parseTheme(allThemes()[name]!, name) + expect(document.light?.categorical).toEqual(["red"]) + expect(parseTheme(allThemes()[name]!, name)).toBe(document) +}) + +test("defers invalid V2 errors until parsing", () => { + const name = `plugin-theme-invalid-v2-${Date.now()}` + expect(addTheme(name, { version: 2, light: { categorical: [] } })).toBe(true) + expect(() => parseTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`) +}) + +test("defers invalid V1 errors until parsing", () => { + const name = `plugin-theme-invalid-v1-${Date.now()}` + const source = structuredClone(DEFAULT_THEMES.opencode) + source.defs = { ...source.defs, one: "two", two: "one" } + source.theme.primary = "one" + + expect(addTheme(name, source)).toBe(true) + expect(() => parseTheme(allThemes()[name]!, name)).toThrow("Circular color reference") +}) + +test("replacement sources receive independent parse caches", () => { + const name = `plugin-theme-replace-${Date.now()}` + const first = structuredClone(DEFAULT_THEMES.opencode) + const second = structuredClone(DEFAULT_THEMES.opencode) + second.theme.primary = "#123456" + + expect(addTheme(name, first)).toBe(true) + const previous = parseTheme(allThemes()[name]!, name) + expect(upsertTheme(name, second)).toBe(true) + const next = parseTheme(allThemes()[name]!, name) + expect(next).not.toBe(previous) + expect(parseTheme(allThemes()[name]!, name)).toBe(next) +}) + +test("custom themes retain precedence over plugin themes", () => { + const name = `plugin-theme-precedence-${Date.now()}` + const plugin = structuredClone(DEFAULT_THEMES.opencode) + const custom = structuredClone(DEFAULT_THEMES.opencode) + + expect(addTheme(name, plugin)).toBe(true) + setCustomThemes({ [name]: custom }) + expect(allThemes()[name]).toBe(custom) + setCustomThemes({}) + expect(allThemes()[name]).toBe(plugin) +}) + test("hasTheme checks theme presence", () => { const name = `plugin-theme-has-${Date.now()}` expect(hasTheme(name)).toBe(false) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index e91a79d2b20..9d42b403413 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -1,16 +1,21 @@ import { expect, test } from "bun:test" import { RGBA } from "@opentui/core" +import { parseTheme, type ThemeDocumentSource } from "../../../src/theme" import { DEFAULT_THEME } from "../../../src/theme/v2/defaults" -import type { ThemeDefinition } from "../../../src/theme/v2" -import { resolveTheme, resolveThemeFile } from "../../../src/theme/v2/resolve" +import type { Mode, ThemeDefinition } from "../../../src/theme/v2" +import { resolveTheme, resolveThemeDocument } from "../../../src/theme/v2/resolve" import { selectTheme } from "../../../src/theme/v2/select" const light = selectTheme(DEFAULT_THEME, "light") const dark = selectTheme(DEFAULT_THEME, "dark") -test("resolves one-mode files with defaults for the available mode", () => { - const resolvedLight = resolveThemeFile({ version: 2, light: {} }, "dark") - const resolvedDark = resolveThemeFile({ version: 2, dark: {} }, "light") +function resolveSource(source: ThemeDocumentSource, mode?: Mode, name?: string) { + return resolveThemeDocument(parseTheme(source, name), mode) +} + +test("resolves one-mode documents with defaults for the available mode", () => { + const resolvedLight = resolveSource({ version: 2, light: {} }, "dark") + const resolvedDark = resolveSource({ version: 2, dark: {} }, "light") expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue() expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue() @@ -18,26 +23,19 @@ test("resolves one-mode files with defaults for the available mode", () => { expect(resolvedDark.categorical.length).toBeGreaterThan(0) }) -test("rejects theme files without a mode", () => { - // @ts-expect-error Runtime decoding also enforces the at-least-one-mode invariant. - expect(() => resolveThemeFile({ version: 2 })).toThrow("Invalid theme") +test("rejects theme documents without a mode", () => { + expect(() => resolveSource({ version: 2 })).toThrow("Invalid theme") }) test("validates and resolves categorical hues in configured order", () => { - const theme = resolveThemeFile({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light") + const theme = resolveSource({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light") expect(theme.categorical[0]).toBe(theme.hue.accent) expect(theme.categorical[1]).toBe(theme.hue.red) expect(theme.categorical[2]).toBe(theme.hue.interactive) expect(theme.contexts["@context:elevated"]?.categorical).toBe(theme.categorical) - expect(() => resolveThemeFile({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme") - expect(() => - resolveThemeFile( - // @ts-expect-error Runtime decoding rejects unknown categorical hue names. - { version: 2, light: { categorical: ["magenta"] } }, - "light", - ), - ).toThrow("Invalid theme") + expect(() => resolveSource({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme") + expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme") }) test("uses the default categorical order for direct definitions", () => { @@ -93,7 +91,7 @@ test("resolves base hue aliases and rejects circular hue aliases", () => { ...light, hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" }, }) - const overridden = resolveThemeFile({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light") + const overridden = resolveSource({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light") expect(aliased.hue.blue).not.toBe(aliased.hue.red) expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue() @@ -131,8 +129,8 @@ test("steps by hue source when adjacent colors have equal values", () => { expect(theme.increase(theme.hue.neutral[300])).toBe(theme.hue.neutral[400]) }) -test("merges partial files with the selected OpenCode defaults", () => { - const theme = resolveThemeFile( +test("merges partial documents with the selected OpenCode defaults", () => { + const theme = resolveSource( { version: 2, light: { @@ -150,7 +148,7 @@ test("merges partial files with the selected OpenCode defaults", () => { }) test("expands user structural fallbacks before merging defaults", () => { - const expanded = resolveThemeFile( + const expanded = resolveSource( { version: 2, light: { @@ -161,7 +159,7 @@ test("expands user structural fallbacks before merging defaults", () => { }, "light", ) - const isolatedState = resolveThemeFile( + const isolatedState = resolveSource( { version: 2, light: { @@ -181,9 +179,9 @@ test("expands user structural fallbacks before merging defaults", () => { }) test("standalone themes skip OpenCode defaults and use the red core fallback", () => { - const file = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const - const lightTheme = resolveThemeFile(file, "light") - const darkTheme = resolveThemeFile(file, "dark") + const document = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const + const lightTheme = resolveSource(document, "light") + const darkTheme = resolveSource(document, "dark") expect(lightTheme.text.default.toInts()).toEqual([255, 0, 0, 255]) expect(lightTheme.background.default.toInts()).toEqual([255, 0, 0, 255]) @@ -192,7 +190,7 @@ test("standalone themes skip OpenCode defaults and use the red core fallback", ( }) test("uses defaults for the selected mode when it merges the other mode", () => { - const theme = resolveThemeFile( + const theme = resolveSource( { version: 2, light: { hue: light.hue, background: { default: "#123456" } }, @@ -217,7 +215,7 @@ test("resolves matched action variants and states", () => { }) test("resolves elevated hover surfaces from direct colors", () => { - const theme = resolveThemeFile( + const theme = resolveSource( { version: 2, light: { background: { surface: { offset: "#123456", overlay: "#234567" } } }, @@ -231,7 +229,7 @@ test("resolves elevated hover surfaces from direct colors", () => { }) test("resolves transparent colors", () => { - const theme = resolveThemeFile({ + const theme = resolveSource({ version: 2, light: { background: { formfield: { default: "transparent" } } }, dark: { background: { formfield: { default: "transparent" } } }, @@ -241,7 +239,7 @@ test("resolves transparent colors", () => { test("reports theme decoding failures as native errors", () => { expect(() => - resolveThemeFile( + resolveSource( { version: 2, light: { text: { default: "opaque" } }, diff --git a/packages/tui/test/theme/v2/select.test.ts b/packages/tui/test/theme/v2/select.test.ts index 792321de13a..00f0bd437de 100644 --- a/packages/tui/test/theme/v2/select.test.ts +++ b/packages/tui/test/theme/v2/select.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2" +import type { HueDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2" import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select" const hue = {} as HueDefinition @@ -11,20 +11,20 @@ const dark = { } satisfies ThemeDefinition test("requires and selects independent light and dark themes", () => { - const file = { version: 2, light, dark } satisfies ThemeFile - expect(selectTheme(file)).toBe(light) - expect(selectTheme(file, "light")).toBe(light) - expect(selectTheme(file, "dark")).toBe(dark) - expect(selectThemeMode(file, "dark").mode).toBe("dark") + const document = { version: 2, light, dark } satisfies ThemeDocument + expect(selectTheme(document)).toBe(light) + expect(selectTheme(document, "light")).toBe(light) + expect(selectTheme(document, "dark")).toBe(dark) + expect(selectThemeMode(document, "dark").mode).toBe("dark") }) test("merges an expanded mode override over the other mode", () => { - const file = { + const document = { version: 2, light, dark: { mergeMode: true, text: { default: "#ffffff" } }, - } satisfies ThemeFile - const selected = selectTheme(file, "dark") + } satisfies ThemeDocument + const selected = selectTheme(document, "dark") expect(selected.hue).toBeDefined() expect(selected.text?.default).toBe("#ffffff") @@ -41,8 +41,8 @@ test("replaces categorical order in a merge mode", () => { }) test("selects the available mode when the requested mode is missing", () => { - const lightOnly = { version: 2, light } satisfies ThemeFile - const darkOnly = { version: 2, dark } satisfies ThemeFile + const lightOnly = { version: 2, light } satisfies ThemeDocument + const darkOnly = { version: 2, dark } satisfies ThemeDocument expect(themeModes(lightOnly)).toEqual(["light"]) expect(themeModes(darkOnly)).toEqual(["dark"]) @@ -62,10 +62,10 @@ test("rejects a merge mode without its base mode", () => { }) test("rejects mutual mode merging", () => { - const file = { + const document = { version: 2, light: { mergeMode: true }, dark: { mergeMode: true }, - } satisfies ThemeFile - expect(() => selectTheme(file)).toThrow("cannot both merge") + } satisfies ThemeDocument + expect(() => selectTheme(document)).toThrow("cannot both merge") }) diff --git a/packages/tui/test/theme/v2/types.test.ts b/packages/tui/test/theme/v2/types.test.ts index a44322d71a2..f98a013f5ef 100644 --- a/packages/tui/test/theme/v2/types.test.ts +++ b/packages/tui/test/theme/v2/types.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2" +import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2" const text = { default: "$hue.neutral.900", @@ -51,11 +51,11 @@ const definition = { "@context:overlay": { background: { default: "$hue.neutral.300" } }, } satisfies ThemeDefinition -const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile -const lightOnly = { version: 2, light: definition } satisfies ThemeFile -const darkOnly = { version: 2, dark: definition } satisfies ThemeFile -// @ts-expect-error A theme file must provide at least one mode. -const empty = { version: 2 } satisfies ThemeFile +const document = { version: 2, light: definition, dark: definition } satisfies ThemeDocument +const lightOnly = { version: 2, light: definition } satisfies ThemeDocument +const darkOnly = { version: 2, dark: definition } satisfies ThemeDocument +// @ts-expect-error A theme document must provide at least one mode. +const empty = { version: 2 } satisfies ThemeDocument test("supports property-first definitions, variants, states, and contexts", () => { expect(text.action.primary.$hovered).toBe("$hue.neutral.200") @@ -68,7 +68,7 @@ test("supports property-first definitions, variants, states, and contexts", () = expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800") expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300") expect(definition.categorical).toEqual(["blue", "accent"]) - expect(file.light).toBe(definition) + expect(document.light).toBe(definition) expect(lightOnly.light).toBe(definition) expect(darkOnly.dark).toBe(definition) expect(empty.version).toBe(2) diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index 84c3639b09f..ced1ae4800a 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme" -import { resolveThemeFile } from "../../../src/theme/v2/resolve" +import { resolveThemeDocument } from "../../../src/theme/v2/resolve" import { selectThemeMode, themeModes } from "../../../src/theme/v2/select" import { migrateV1 } from "../../../src/theme/v2/v1-migrate" import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "../../../src/theme/v2/defaults" @@ -9,7 +9,7 @@ test("migrates resolved V1 modes into literal V2 tokens", () => { const migrated = migrateV1(DEFAULT_THEMES.opencode) if (!migrated.light || !migrated.dark) throw new Error("Expected both modes") const legacy = resolveV1(DEFAULT_THEMES.opencode, "light") - const resolved = resolveThemeFile(migrated, "light") + const resolved = resolveThemeDocument(migrated, "light") expect(migrated.standalone).toBeTrue() expect(migrated.light.categorical?.length).toBeGreaterThan(0) @@ -83,8 +83,8 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou expect(migrated.light.hue?.purple).toBe("$hue.gray") expect(migrated.light.hue?.accent).toBe("$hue.gray") expect(migrated.light.hue?.interactive).toBe("$hue.gray") - expect(() => resolveThemeFile(migrated, "light")).not.toThrow() - expect(() => resolveThemeFile(migrated, "dark")).not.toThrow() + expect(() => resolveThemeDocument(migrated, "light")).not.toThrow() + expect(() => resolveThemeDocument(migrated, "dark")).not.toThrow() }) test("orders categorical hues by V1 semantic color mapping", () => { @@ -185,7 +185,7 @@ test("migrates every built-in V1 theme in its supported modes", () => { for (const source of Object.values(DEFAULT_THEMES)) { const migrated = migrateV1(source) for (const mode of themeModes(migrated)) { - expect(resolveThemeFile(migrated, mode).text.default).toBeDefined() + expect(resolveThemeDocument(migrated, mode).text.default).toBeDefined() } } }) From 74e92f73e034dfcfbd092ae12d8ff2ea2300414e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:32:33 -0500 Subject: [PATCH 20/30] refactor(ai): remove unused response format (#38540) Co-authored-by: Aiden Cline --- packages/ai/src/llm.ts | 2 +- packages/ai/src/schema/messages.ts | 9 --------- packages/core/src/aisdk.ts | 7 ------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index e4781d8608b..ecdf30ae47d 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -81,7 +81,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object" const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." -type GenerateObjectBase = Omit +type GenerateObjectBase = Omit export class GenerateObjectResponse { constructor( diff --git a/packages/ai/src/schema/messages.ts b/packages/ai/src/schema/messages.ts index 4a9de3a735e..e6617ddc9ed 100644 --- a/packages/ai/src/schema/messages.ts +++ b/packages/ai/src/schema/messages.ts @@ -261,13 +261,6 @@ export namespace ToolChoice { } } -export const ResponseFormat = Schema.Union([ - Schema.Struct({ type: Schema.Literal("text") }), - Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }), - Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }), -]).pipe(Schema.toTaggedUnion("type")) -export type ResponseFormat = Schema.Schema.Type - export class LLMRequest extends Schema.Class("LLM.Request")({ id: Schema.optional(Schema.String), model: ModelSchema, @@ -278,7 +271,6 @@ export class LLMRequest extends Schema.Class("LLM.Request")({ generation: Schema.optional(GenerationOptions), providerOptions: Schema.optional(ProviderOptions), http: Schema.optional(HttpOptions), - responseFormat: Schema.optional(ResponseFormat), cache: Schema.optional(CachePolicy), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} @@ -296,7 +288,6 @@ export namespace LLMRequest { generation: request.generation, providerOptions: request.providerOptions, http: request.http, - responseFormat: request.responseFormat, cache: request.cache, metadata: request.metadata, }) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 6d749b8526f..33a4d670af3 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -422,7 +422,6 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions { presencePenalty: request.generation?.presencePenalty, frequencyPenalty: request.generation?.frequencyPenalty, seed: request.generation?.seed, - responseFormat: responseFormat(request), tools: request.tools.map(tool), toolChoice: toolChoice(request.toolChoice), headers: request.http?.headers, @@ -527,12 +526,6 @@ function toolChoice(input: LLMRequest["toolChoice"]): LanguageModelV3ToolChoice return { type: input.type } } -function responseFormat(request: LLMRequest): LanguageModelV3CallOptions["responseFormat"] { - if (request.responseFormat?.type === "json") - return { type: "json", schema: request.responseFormat.schema as JSONSchema7 } - if (request.responseFormat) return { type: "text" } -} - function providerOptions(input: LLMRequest["providerOptions"]): SharedV3ProviderOptions | undefined { if (!input) return undefined return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)])) From ad596fb42bfbf29a2585babbae1e987c54240eea Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:34:24 -0500 Subject: [PATCH 21/30] chore(core): upgrade fff to 0.10.1 (#38545) Co-authored-by: Aiden Cline --- bun.lock | 22 +++++++++--------- package.json | 1 - packages/core/package.json | 2 +- patches/@ff-labs%2Ffff-bun@0.9.3.patch | 31 -------------------------- 4 files changed, 13 insertions(+), 43 deletions(-) delete mode 100644 patches/@ff-labs%2Ffff-bun@0.9.3.patch diff --git a/bun.lock b/bun.lock index 72db1b9e928..24e3c97c4ed 100644 --- a/bun.lock +++ b/bun.lock @@ -362,7 +362,7 @@ "@aws-sdk/credential-providers": "3.1057.0", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@ff-labs/fff-bun": "0.9.4", + "@ff-labs/fff-bun": "0.10.1", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/ai": "workspace:*", @@ -1641,23 +1641,25 @@ "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], - "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="], + "@ff-labs/fff-bin-android-arm64": ["@ff-labs/fff-bin-android-arm64@0.10.1", "", { "os": "android", "cpu": "arm64" }, "sha512-6Bsaa6yKEd2HV1M2WtqSYhoZucKYffIUms6GYoPN48QHP8hZgO8GXJ85/JDxKlkCJsN2hw1ROiwQK0+xUHYhFQ=="], - "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="], + "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7yUP+56sG3UTrLg7eepOD16yM2dgiD7g6Ase2XWQB7oXwLV7mBMylPKIli2pP3kHzfw9K+BS3WAwHKHJ2QhxYw=="], - "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="], + "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-9zb+P1xtyqu/jVklm5RKF2zm9doRRsBNbkF/a8S4aSqSJoNlQR8ZF7C129fzOLfffVAjjgcO2l8oJgxOzHYiwQ=="], - "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="], + "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-KTwr9CUfCJv0vtG2xG+nMxDae/2NJY2/oVmtgSvTnH9baI6JprqsFGphbukx7mdW/QMPwBiYtoO8ZGoC7i92jA=="], - "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="], + "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-oznmSpV+zjiAPiwqbA4y+SAsMaYvDhGllHiT9L4ebdQnSOfcQzlUwvo45OhBPwHBt47tIois4bkF/MITlDk54A=="], - "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="], + "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-KDpl8lwSEOauP/6FSJIvnARzE+ILm2rVIRQAio9dc5nn56EvlsryBWry0c5V0Tbw1PV0lNrZ+bzcIiPuTacvzw=="], - "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="], + "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-mZCpojVtGNDr/wdCUEAHdfrl6qORvKHv3Pw35TaOdshnG4wocoA5bBKTGj/zmAU11o0GMK4V+wUhdNrgqoE10w=="], - "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="], + "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-SbT76ETXC5AgV9J8sVDozKG8wzrrxsOn8lbBprlOtx+O5V5EYmS1W7BlsatTHy6ddQ3uU5oOYO04PWZBuP6wXg=="], - "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="], + "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dcpHUCBEZoXKQCdKa3bADfgcNyeyfM8tatXrILqIFYi9GL8E4GnzKx1rGkgP5ukVtuj0WuoJyyqSvGjpJPIT8w=="], + + "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="], "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], diff --git a/package.json b/package.json index 7bb37f946f4..2110c4d9af0 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,6 @@ "@types/node": "catalog:" }, "patchedDependencies": { - "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", diff --git a/packages/core/package.json b/packages/core/package.json index cf54590d52e..7756c08d7e0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,7 +91,7 @@ "@effect/sql-sqlite-bun": "catalog:", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", - "@ff-labs/fff-bun": "0.9.4", + "@ff-labs/fff-bun": "0.10.1", "@opencode-ai/codemode": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", diff --git a/patches/@ff-labs%2Ffff-bun@0.9.3.patch b/patches/@ff-labs%2Ffff-bun@0.9.3.patch deleted file mode 100644 index 23a7dd54fb1..00000000000 --- a/patches/@ff-labs%2Ffff-bun@0.9.3.patch +++ /dev/null @@ -1,31 +0,0 @@ -diff --git a/src/download.ts b/src/download.ts -index 3454256..6dca25a 100644 ---- a/src/download.ts -+++ b/src/download.ts -@@ -7,7 +7,7 @@ - */ - -+declare const FFF_LIBC: "gnu" | "musl"; - import { existsSync } from "node:fs"; --import { createRequire } from "node:module"; - import { dirname, join } from "node:path"; - import { fileURLToPath } from "node:url"; - import { getLibFilename, getNpmPackageName } from "./platform"; -@@ -54,14 +54,10 @@ export function binaryExists(): boolean { - * in the same directory. - */ - function resolveFromNpmPackage(): string | null { -- const packageName = getNpmPackageName(); -- - try { -- // Use createRequire to resolve the platform package's location -- const require = createRequire(join(getPackageDir(), "package.json")); -- const packageJsonPath = require.resolve(`${packageName}/package.json`); -- const packageDir = dirname(packageJsonPath); -- const binaryPath = join(packageDir, getLibFilename()); -+ const binaryPath = require( -+ `@ff-labs/fff-bin-${process.platform === "linux" ? `linux-${process.arch}-${typeof FFF_LIBC === "string" ? FFF_LIBC : getNpmPackageName().endsWith("musl") ? "musl" : "gnu"}` : `${process.platform}-${process.arch}`}/${process.platform === "win32" ? "fff_c.dll" : process.platform === "darwin" ? "libfff_c.dylib" : "libfff_c.so"}`, -+ ); - - if (existsSync(binaryPath)) { - return binaryPath; From 360e7b412d64ac6ad8bd63415dbf68af0a47c60a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:37:16 -0400 Subject: [PATCH 22/30] feat(tui): expose debug settings (#38546) Co-authored-by: James Long --- packages/tui/src/component/dialog-config.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 8ff9db50c70..1ca585ec137 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -222,6 +222,14 @@ const settings: Setting[] = [ values: [false, true], labels: ["off", "on"], }, + { + title: "Turn token usage", + category: "Debug", + path: ["debug", "turn_tokens"], + default: false, + values: [false, true], + labels: ["off", "on"], + }, ] export function DialogConfig() { From 18fccac6ff8b6e27786869f2d03d86e1b6b1ac1a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:59:51 -0400 Subject: [PATCH 23/30] fix(tui): preserve first message in new sessions (#38542) Co-authored-by: Kit Langton Co-authored-by: James Long <17031+jlongster@users.noreply.github.com> --- packages/tui/src/context/data.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index ddf3823e756..a5a18ff077f 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -298,6 +298,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.created": result.session.invalidate(event.data.sessionID) void result.session.sync(event.data.sessionID) + // Band-aid: a newly created session starts empty, so live events can be its source of truth. + // Fetching pending inputs and projected messages separately lets promotion move an input between snapshots, + // causing both requests to miss it and overwrite event-built state. Skip those racy initial reads until + // hydration can load pending and projected messages atomically. + sync.complete(`session.pending:${event.data.sessionID}`) + sync.complete(`session.message:${event.data.sessionID}`) break case "session.deleted": removeSession(event.data.sessionID) From 2c814120c7f918237812510655fed067ccb902a3 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:52:20 -0500 Subject: [PATCH 24/30] fix(ai): keep tools when Anthropic tool_choice is none (#38553) --- .../ai/src/protocols/anthropic-messages.ts | 10 ++++--- .../test/provider/anthropic-messages.test.ts | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index f5b9ea67d9f..cc03dfa65b6 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -159,7 +159,7 @@ const AnthropicTool = Schema.Struct({ type AnthropicTool = Schema.Schema.Type const AnthropicToolChoice = Schema.Union([ - Schema.Struct({ type: Schema.Literals(["auto", "any"]) }), + Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }), Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }), ]) @@ -297,7 +297,7 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc const lowerToolChoice = (toolChoice: NonNullable) => ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, { auto: () => ({ type: "auto" as const }), - none: () => undefined, + none: () => ({ type: "none" as const }), required: () => ({ type: "any" as const }), tool: (name) => ({ type: "tool" as const, name }), }) @@ -542,7 +542,6 @@ const outputConfig = (request: LLMRequest) => { } const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { - const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096 @@ -551,7 +550,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques // over-mark we keep their tool hints and shed the message-tail ones first. const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) const tools = - request.tools.length === 0 || request.toolChoice?.type === "none" + request.tools.length === 0 ? undefined : request.tools.map((tool) => lowerTool( @@ -560,6 +559,9 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), ), ) + // Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present. + const toolChoice = + tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice) const system = request.system.length === 0 ? undefined diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 30b3c1b6b86..da8bb342876 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -235,6 +235,34 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("keeps tools and sends tool_choice none", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_choice_none", + model, + tools: [{ name: "lookup", description: "Look things up", inputSchema: { type: "object", properties: {} } }], + messages: [ + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + toolChoice: "none", + cache: "none", + }), + ) + + expect(prepared.body.tools).toEqual([ + { + name: "lookup", + description: "Look things up", + input_schema: { type: "object", properties: {} }, + }, + ]) + expect(prepared.body.tool_choice).toEqual({ type: "none" }) + }), + ) + // Regression: read tool results must stay structured so base64 media data is // not JSON-stringified into `tool_result.content`. it.effect("lowers media tool-result content as structured blocks", () => From 2a9f8e3a2cb78d28f345e5f45f9ba1c222068e8f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:58:02 +0000 Subject: [PATCH 25/30] fix(tui): manage focus in devtools panels (#38555) Co-authored-by: James Long <17031+jlongster@users.noreply.github.com> --- packages/tui/src/component/devtools-bar.tsx | 25 +++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx index 03373110e22..45c031069c2 100644 --- a/packages/tui/src/component/devtools-bar.tsx +++ b/packages/tui/src/component/devtools-bar.tsx @@ -1,4 +1,4 @@ -import { TextAttributes } from "@opentui/core" +import { TextAttributes, type Renderable } from "@opentui/core" import { TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" import { open } from "node:fs/promises" import { tmpdir } from "node:os" @@ -33,6 +33,7 @@ export function DevToolsBar() { const plugins = usePlugin() const theme = useTheme() const keymap = Keymap.use() + const renderer = useRenderer() const dimensions = useTerminalDimensions() const { themeV2, mode, supports, setMode } = theme const elevatedTheme = theme.contextual("elevated").themeV2 @@ -41,6 +42,7 @@ export function DevToolsBar() { const [dumpPath, setDumpPath] = createSignal() const [dumpError, setDumpError] = createSignal() const [frontendSamples, setFrontendSamples] = createSignal([]) + let focus: Renderable | null const connected = createMemo(() => client.connection.status() === "connected") const serverIndicator = createMemo(() => connectionIndicator(client.connection.status(), client.connection.attempt())) const themePerformance = createMemo( @@ -54,7 +56,22 @@ export function DevToolsBar() { address: info.urls[0] ? new URL(info.urls[0]).host : "Unknown", } }) - const toggle = (next: Panel) => setPanel((current) => (current === next ? undefined : next)) + const close = () => { + setPanel() + setTimeout(() => { + if (panel() || !focus || focus.isDestroyed) return + focus.focus() + focus = null + }, 1) + } + const toggle = (next: Panel) => { + if (panel() === next) return close() + if (!panel()) { + focus = renderer.currentFocusedRenderable + focus?.blur() + } + setPanel(next) + } const nextMode = () => (mode() === "dark" ? "light" : "dark") const canSwitchMode = () => supports(nextMode()) const runtime = createMemo(() => runtimeStatus(frontendSamples())) @@ -67,7 +84,7 @@ export function DevToolsBar() { if (!panel() || event.name !== "escape") return event.preventDefault() event.stopPropagation() - setPanel() + close() }, { priority: 10 }, ) @@ -205,7 +222,7 @@ export function DevToolsBar() { width={dimensions().width} height={Math.max(0, dimensions().height - 1)} backgroundColor="transparent" - onMouseUp={() => setPanel()} + onMouseUp={close} /> toggle("server")}> From 193f6be99ca3e70f8ca001a7eb4f66cf78383fe9 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:09:25 -0500 Subject: [PATCH 26/30] fix(ai): keep tools when Gemini tool choice is none (#38556) --- packages/ai/src/protocols/gemini.ts | 6 +++--- packages/ai/test/provider/gemini.test.ts | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 6cba2bc7f9b..57ad0660372 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -313,7 +313,7 @@ const thinkingConfig = (request: LLMRequest) => { } const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { - const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none" + const hasTools = request.tools.length > 0 const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema const generationConfig = { @@ -329,7 +329,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque contents: yield* lowerMessages(request), systemInstruction: request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, - tools: toolsEnabled + tools: hasTools ? [ { functionDeclarations: request.tools.map((tool) => @@ -338,7 +338,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque }, ] : undefined, - toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, + toolConfig: hasTools && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, generationConfig: Object.values(generationConfig).some((value) => value !== undefined) ? generationConfig : undefined, diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 5195d372c90..50b5dbb1d25 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -233,11 +233,11 @@ describe("Gemini route", () => { }), ) - it.effect("omits tools when tool choice is none", () => + it.effect("keeps tools and sends function calling mode NONE", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( LLM.request({ - id: "req_no_tools", + id: "req_tool_choice_none", model, prompt: "Say hello.", tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], @@ -245,8 +245,10 @@ describe("Gemini route", () => { }), ) - expect(prepared.body).toEqual({ + expect(prepared.body).toMatchObject({ contents: [{ role: "user", parts: [{ text: "Say hello." }] }], + tools: [{ functionDeclarations: [{ name: "lookup", description: "Lookup data" }] }], + toolConfig: { functionCallingConfig: { mode: "NONE" } }, }) }), ) From 8cac010bacc9ddd374c900d6a7c98aafc1113f1f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:12:23 -0500 Subject: [PATCH 27/30] fix(core): stop forcing toolChoice none on session.generate (#38557) --- packages/core/src/session/generate-node.ts | 1 - packages/core/test/session-generate.test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index f26e2847c2f..96804fc57f7 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -74,7 +74,6 @@ export const layer = Layer.effect( system: contextEvent.system, messages: contextEvent.messages, tools: hookedTools, - toolChoice: "none", }), ) yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage }) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 98e7aedc0c7..e78f41bdac0 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -301,7 +301,7 @@ it.effect("generates from fresh settled Session context without durable mutation ), ).toEqual(["Settled partial answer"]) expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }]) - expect(requests[0]?.toolChoice).toMatchObject({ type: "none" }) + expect(requests[0]?.toolChoice).toBeUndefined() expect(yield* durableState(db, sessionID)).toEqual(before) }), ) From 79c1544072739f58d2e80f8c3e83b203aa92d827 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 23 Jul 2026 17:13:31 -0400 Subject: [PATCH 28/30] refactor(tools): unify tool APIs and result handling (#38367) --- .changeset/canonical-tool-results.md | 8 + .../ai/src/protocols/anthropic-messages.ts | 9 +- packages/ai/src/protocols/bedrock-converse.ts | 9 +- .../test/provider/anthropic-messages.test.ts | 9 +- .../ai/test/provider/bedrock-converse.test.ts | 30 ++ packages/cli/src/acp/event.ts | 23 +- packages/cli/src/acp/permission.ts | 6 +- packages/cli/src/acp/tool.ts | 37 +- .../cli/src/node/plugin-runtime.promise.ts | 2 + packages/cli/src/run/noninteractive.ts | 48 +-- packages/cli/test/acp/event-behavior.test.ts | 29 +- .../cli/test/acp/permission-behavior.test.ts | 6 +- packages/cli/test/acp/tool.test.ts | 63 +-- packages/cli/test/import-boundaries.test.ts | 7 +- packages/cli/test/run/noninteractive.test.ts | 25 +- packages/cli/vite.node.config.ts | 12 +- .../client/src/promise/generated/types.ts | 59 ++- packages/codemode/README.md | 8 +- packages/codemode/interpreter-support.md | 2 +- packages/codemode/src/interpreter/execute.ts | 2 +- packages/codemode/src/interpreter/runtime.ts | 13 +- packages/codemode/src/openapi/index.ts | 10 +- packages/codemode/src/openapi/types.ts | 4 +- packages/codemode/src/tool-runtime.ts | 72 ++-- packages/codemode/src/tool-schema.ts | 44 +- packages/codemode/src/tool.ts | 24 +- packages/codemode/src/tools.ts | 4 +- packages/codemode/test/callbacks.test.ts | 2 +- packages/codemode/test/codemode.test.ts | 74 ++-- packages/codemode/test/enumeration.test.ts | 2 +- packages/codemode/test/openapi.test.ts | 149 ++++--- packages/codemode/test/promise.test.ts | 16 +- packages/codemode/test/signature.test.ts | 14 +- packages/codemode/test/stdlib.test.ts | 4 +- packages/codemode/test/tool-paths.test.ts | 10 +- packages/core/src/codemode.ts | 39 +- packages/core/src/database/migration.gen.ts | 1 + .../20260722170000_canonical_tool_results.ts | 123 ++++++ packages/core/src/plugin/host.ts | 42 +- packages/core/src/plugin/promise.ts | 22 +- packages/core/src/session/generate-node.ts | 9 +- packages/core/src/session/message-updater.ts | 18 +- packages/core/src/session/model-request.ts | 43 +- packages/core/src/session/runner/llm.ts | 25 +- .../src/session/runner/publish-llm-event.ts | 103 +++-- .../core/src/session/runner/to-llm-message.ts | 40 +- packages/core/src/session/to-session-error.ts | 9 +- packages/core/src/tool-output-store.ts | 36 +- packages/core/src/tool/AGENTS.md | 18 +- packages/core/src/tool/edit.ts | 18 +- packages/core/src/tool/execute.ts | 101 +++-- packages/core/src/tool/glob.ts | 22 +- packages/core/src/tool/grep.ts | 28 +- packages/core/src/tool/hooks.ts | 27 +- packages/core/src/tool/mcp.ts | 146 +++---- packages/core/src/tool/patch.ts | 17 +- packages/core/src/tool/question.ts | 10 +- packages/core/src/tool/read.ts | 28 +- packages/core/src/tool/registry.ts | 276 +++++++------ packages/core/src/tool/shell.ts | 62 +-- packages/core/src/tool/skill.ts | 16 +- packages/core/src/tool/subagent.ts | 18 +- packages/core/src/tool/tool.ts | 90 +++- packages/core/src/tool/tools.ts | 4 +- packages/core/src/tool/webfetch.ts | 10 +- packages/core/src/tool/websearch.ts | 10 +- packages/core/src/tool/write.ts | 9 +- packages/core/test/codemode.test.ts | 16 +- packages/core/test/database-migration.test.ts | 203 +++++++++ packages/core/test/lib/tool.ts | 11 +- packages/core/test/mcp.test.ts | 94 ++++- packages/core/test/plugin.test.ts | 53 ++- packages/core/test/plugin/promise.test.ts | 41 +- packages/core/test/session-generate.test.ts | 4 +- .../core/test/session-instructions.test.ts | 14 +- .../core/test/session-runner-message.test.ts | 41 +- .../test/session-runner-tool-events.test.ts | 90 ++-- .../test/session-runner-tool-registry.test.ts | 330 ++++++++------- packages/core/test/session-runner.test.ts | 386 ++++++++++-------- .../core/test/session-tool-progress.test.ts | 20 +- packages/core/test/tool-edit.test.ts | 86 ++-- packages/core/test/tool-execute.test.ts | 116 ++++-- packages/core/test/tool-output-store.test.ts | 76 +--- packages/core/test/tool-patch.test.ts | 171 ++++---- packages/core/test/tool-question.test.ts | 31 +- packages/core/test/tool-read.test.ts | 145 ++++--- packages/core/test/tool-search.test.ts | 25 +- packages/core/test/tool-shell.test.ts | 120 +++--- packages/core/test/tool-skill.test.ts | 34 +- packages/core/test/tool-subagent.test.ts | 72 ++-- packages/core/test/tool-webfetch.test.ts | 83 ++-- packages/core/test/tool-websearch.test.ts | 32 +- packages/core/test/tool-write.test.ts | 58 +-- packages/docs/build/plugins.mdx | 75 ++-- .../plugin/src/v2/effect/internal/tool.ts | 315 ++++++++++++++ packages/plugin/src/v2/effect/tool.ts | 316 +------------- packages/plugin/src/v2/promise/README.md | 20 +- .../plugin/src/v2/promise/internal/tool.ts | 64 +++ packages/plugin/src/v2/promise/tool.ts | 50 +-- packages/plugin/test/tool.test.ts | 69 ++-- packages/schema/src/session-event.ts | 29 +- packages/schema/src/session-message.ts | 13 +- packages/schema/test/event-manifest.test.ts | 4 +- packages/sdk-next/src/tool.ts | 2 +- packages/sdk-next/test/embedded.test.ts | 6 +- .../src/backend/simulated-provider.ts | 24 +- packages/simulation/src/protocol/index.ts | 5 +- packages/simulation/test/protocol.test.ts | 2 +- .../test/simulated-provider.test.ts | 110 ++--- packages/tui/src/context/data.tsx | 14 +- packages/tui/src/mini/demo.ts | 13 +- packages/tui/src/mini/permission.shared.ts | 2 +- packages/tui/src/mini/stream-v2.subagent.ts | 45 +- packages/tui/src/mini/stream-v2.transport.ts | 20 +- packages/tui/src/mini/stream.ts | 2 +- packages/tui/src/mini/tool.public.ts | 3 +- packages/tui/src/mini/tool.ts | 49 ++- packages/tui/src/routes/session/index.tsx | 80 +++- .../tui/src/routes/session/permission.tsx | 10 +- packages/tui/src/util/permission.ts | 4 +- packages/tui/src/util/tool-display.ts | 18 +- packages/tui/test/cli/tui/data.test.tsx | 11 +- packages/tui/test/mini/entry.body.test.ts | 41 +- .../tui/test/mini/permission.shared.test.ts | 9 +- .../tui/test/mini/scrollback.surface.test.ts | 30 +- .../tui/test/mini/stream-v2.transport.test.ts | 82 ++-- packages/tui/test/mini/tool.test.ts | 40 +- packages/tui/test/util/tool-display.test.ts | 16 +- packages/www/content/docs/build/plugins.mdx | 73 ++-- specs/v2/README.md | 2 +- specs/v2/schema-changelog.md | 15 +- specs/v2/session.md | 4 +- specs/v2/tools.md | 71 ++-- 133 files changed, 3599 insertions(+), 2767 deletions(-) create mode 100644 .changeset/canonical-tool-results.md create mode 100644 packages/core/src/database/migration/20260722170000_canonical_tool_results.ts create mode 100644 packages/plugin/src/v2/effect/internal/tool.ts create mode 100644 packages/plugin/src/v2/promise/internal/tool.ts diff --git a/.changeset/canonical-tool-results.md b/.changeset/canonical-tool-results.md new file mode 100644 index 00000000000..b484966033d --- /dev/null +++ b/.changeset/canonical-tool-results.md @@ -0,0 +1,8 @@ +--- +"@opencode-ai/plugin": minor +"@opencode-ai/sdk": minor +"@opencode-ai/client": minor +"@opencode-ai/protocol": minor +--- + +Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state. diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index cc03dfa65b6..fc4ce9ab539 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -330,7 +330,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult const wireType = serverToolResultType(part.name) if (!wireType) return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`) - return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock + // Prefer the provider-owned replay payload; fall back to the result value for + // histories constructed directly from provider events. + const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value + return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock }) const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) { @@ -682,7 +685,9 @@ const serverToolResultEvent = (block: NonNullable 0 && request.toolChoice?.type !== "none" - ? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice } + request.tools.length > 0 + ? { + tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), + // Converse has no native "none". Keep definitions stable for prompt + // caching and omit only the unsupported choice. + toolChoice, + } : undefined const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) const messages = yield* lowerMessages(request, breakpoints) diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index da8bb342876..0b319f573b8 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -664,7 +664,14 @@ describe("Anthropic Messages route", () => { name: "web_search", result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] }, providerExecuted: true, - providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, + // The complete payload rides in provider metadata as irreducible replay + // state for later stateless requests. + providerMetadata: { + anthropic: { + blockType: "web_search_tool_result", + result: [{ type: "web_search_result", url: "https://example.com", title: "Example" }], + }, + }, }) expect(response.text).toBe("Found it.") expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop" }) diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index 776032966c8..a7280e09cd9 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -154,6 +154,36 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("keeps tools and omits the unsupported choice when tool choice is none", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(baseRequest, { + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + toolChoice: ToolChoice.make({ type: "none" }), + }), + ) + + expect(prepared.body.toolConfig).toMatchObject({ + tools: [ + { + toolSpec: { + name: "lookup", + description: "Lookup data", + inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } }, + }, + }, + ], + }) + expect(prepared.body.toolConfig?.toolChoice).toBeUndefined() + }), + ) + it.effect("lowers assistant tool-call + tool-result message history", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index 15513538300..187b660141c 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -28,7 +28,7 @@ export type TurnControl = { type ToolState = { readonly name: string input: ToolInput - structured: Record + metadata: Record content: ToolContent } @@ -38,7 +38,7 @@ export type TurnStart = | { readonly type: "compaction"; readonly id: string } function emptyToolState(): ToolState { - return { name: "tool", input: {}, structured: {}, content: [] } + return { name: "tool", input: {}, metadata: {}, content: [] } } export async function streamTurn(input: { @@ -120,7 +120,7 @@ export async function streamTurn(input: { } if (event.type === "session.tool.input.started") { assistantMessageID = event.data.assistantMessageID - tools.set(event.data.callID, { name: event.data.name, input: {}, structured: {}, content: [] }) + tools.set(event.data.callID, { name: event.data.name, input: {}, metadata: {}, content: [] }) await update({ sessionUpdate: "tool_call", ...pendingToolCall({ @@ -151,15 +151,13 @@ export async function streamTurn(input: { if (event.type === "session.tool.progress") { const current = tools.get(event.data.callID) if (!current) continue - current.structured = event.data.structured - current.content = event.data.content + current.metadata = event.data.metadata await update({ sessionUpdate: "tool_call_update", ...runningToolUpdate({ toolCallId: event.data.callID, toolName: current.name, state: { input: current.input }, - content: current.content, cwd: input.cwd, }), }) @@ -175,7 +173,7 @@ export async function streamTurn(input: { cwd: input.cwd, toolName: current.name, toolInput: current.input, - structured: event.data.structured, + metadata: event.data.metadata ?? {}, }).catch(() => {}) await update({ sessionUpdate: "tool_call_update", @@ -183,9 +181,8 @@ export async function streamTurn(input: { toolCallId: event.data.callID, toolName: current.name, input: current.input, - structured: event.data.structured, + metadata: event.data.metadata, content: event.data.content, - result: event.data.result, }), }) continue @@ -199,7 +196,7 @@ export async function streamTurn(input: { toolCallId: event.data.callID, toolName: current.name, input: current.input, - structured: event.data.metadata ?? current.structured, + metadata: event.data.metadata ?? current.metadata, content: event.data.content ?? current.content, error: event.data.error.message, cwd: input.cwd, @@ -342,9 +339,8 @@ async function replayMessage( toolCallId: part.id, toolName: part.name, input: part.state.input, - structured: part.state.structured, + metadata: part.state.metadata, content: part.state.content, - result: part.state.result, }), }, }) @@ -358,7 +354,6 @@ async function replayMessage( toolCallId: part.id, toolName: part.name, state: { input: part.state.input }, - content: part.state.content, cwd, }), }, @@ -373,7 +368,7 @@ async function replayMessage( toolCallId: part.id, toolName: part.name, input: part.state.input, - structured: part.state.structured, + metadata: part.state.metadata, content: part.state.content, error: part.state.error.message, cwd, diff --git a/packages/cli/src/acp/permission.ts b/packages/cli/src/acp/permission.ts index 3c1d92957e4..87f6e68d06b 100644 --- a/packages/cli/src/acp/permission.ts +++ b/packages/cli/src/acp/permission.ts @@ -58,11 +58,11 @@ export async function syncEditedFiles(input: { readonly cwd: string readonly toolName: string readonly toolInput: ToolInput - readonly structured: Readonly> + readonly metadata: Readonly> }) { if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return - const files = Array.isArray(input.structured.files) - ? input.structured.files.flatMap((file): string[] => { + const files = Array.isArray(input.metadata.files) + ? input.metadata.files.flatMap((file): string[] => { if (!file || typeof file !== "object") return [] const path = Reflect.get(file, "file") return typeof path === "string" ? [path] : [] diff --git a/packages/cli/src/acp/tool.ts b/packages/cli/src/acp/tool.ts index 468512f892b..8937712f11b 100644 --- a/packages/cli/src/acp/tool.ts +++ b/packages/cli/src/acp/tool.ts @@ -1,5 +1,6 @@ import { isAbsolute, resolve } from "node:path" import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk" +import { readDisplayText } from "@opencode-ai/tui/mini/tool" export type ToolInput = Record export type ToolContent = ReadonlyArray< @@ -100,11 +101,12 @@ export function completedToolUpdate(input: { readonly toolName: string readonly input: ToolInput readonly content: ToolContent - readonly structured: Readonly> - readonly result?: unknown + readonly metadata?: Readonly> }): ToolCallUpdate { const normalized = toolContent(input.content) - const read = input.toolName.toLocaleLowerCase() === "read" ? readDisplayText(input.structured) : undefined + // Read's model content is a JSON page envelope; show the clean text instead. + const firstText = input.content.find((part) => part.type === "text") + const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined const images = normalized.filter((part) => part.type === "content" && part.content.type === "image") const primary = read === undefined @@ -128,8 +130,7 @@ export function completedToolUpdate(input: { status: "completed", content: [...primary, ...diff, ...images], rawOutput: { - structured: input.structured, - ...(input.result === undefined ? {} : { result: input.result }), + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), }, } } @@ -138,8 +139,8 @@ export function errorToolUpdate(input: { readonly toolCallId: string readonly toolName: string readonly input: ToolInput - readonly content: ToolContent - readonly structured: Readonly> + readonly content?: ToolContent + readonly metadata?: Readonly> readonly error: string readonly cwd?: string }): ToolCallUpdate { @@ -150,8 +151,11 @@ export function errorToolUpdate(input: { title: toolTitle(input.toolName, input.input, undefined), locations: toLocations(input.toolName, input.input, input.cwd), rawInput: rawInput(input.toolName, input.input, input.cwd), - content: [...toolContent(input.content), { type: "content", content: { type: "text", text: input.error } }], - rawOutput: { structured: input.structured, error: input.error }, + content: [...toolContent(input.content ?? []), { type: "content", content: { type: "text", text: input.error } }], + rawOutput: { + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + error: input.error, + }, } } @@ -164,21 +168,6 @@ function toolContent(content: ToolContent): ToolCallContent[] { }) } -function readDisplayText(structured: Readonly>) { - if (typeof structured.content === "string") { - if (structured.type === "text-page" || structured.encoding === "utf8") return structured.content - } - if (!Array.isArray(structured.entries)) return undefined - return structured.entries - .flatMap((entry): string[] => { - if (typeof entry === "string") return [entry] - if (!entry || typeof entry !== "object") return [] - const path = Reflect.get(entry, "path") - return typeof path === "string" ? [path] : [] - }) - .join("\n") -} - function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) { if (isShell(toolName)) return stringValue(input.command) ?? stringValue(input.cmd) ?? fallback ?? toolName return fallback || toolName diff --git a/packages/cli/src/node/plugin-runtime.promise.ts b/packages/cli/src/node/plugin-runtime.promise.ts index 93e20b87bb1..49c46512328 100644 --- a/packages/cli/src/node/plugin-runtime.promise.ts +++ b/packages/cli/src/node/plugin-runtime.promise.ts @@ -10,6 +10,7 @@ import { Reference, Skill, } from "@opencode-ai/plugin/v2" +import { Tool } from "@opencode-ai/plugin/v2/tool" const key = Symbol.for("opencode.plugin.v2.promise") ;(globalThis as typeof globalThis & { [key]?: unknown })[key] = { @@ -23,4 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.promise") Provider, Reference, Skill, + Tool, } diff --git a/packages/cli/src/run/noninteractive.ts b/packages/cli/src/run/noninteractive.ts index 41859d3b64f..824d228c6a5 100644 --- a/packages/cli/src/run/noninteractive.ts +++ b/packages/cli/src/run/noninteractive.ts @@ -10,7 +10,7 @@ import type { import { SessionMessage } from "@opencode-ai/schema/session-message" import { EOL } from "node:os" import { readFile } from "node:fs/promises" -import { toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool" +import { nonEmptyToolContent, toolOutputText, type MiniToolPart } from "@opencode-ai/tui/mini/tool" import { UI } from "./ui" type Model = { @@ -55,7 +55,7 @@ type ToolState = StartedPart & { raw?: string provider?: unknown providerState?: SessionMessageAssistantTool["providerState"] - structured: Record + metadata: Record content: LLMToolContent[] } @@ -306,7 +306,7 @@ export async function runNonInteractivePrompt(input: Input) { assistantMessageID: event.data.assistantMessageID, tool: event.data.name, input: {}, - structured: {}, + metadata: {}, content: [], }) continue @@ -334,7 +334,7 @@ export async function runNonInteractivePrompt(input: Input) { raw: current?.raw, provider: { executed: event.data.executed, state: event.data.state }, providerState: event.data.state, - structured: {}, + metadata: {}, content: [], }) continue @@ -342,8 +342,7 @@ export async function runNonInteractivePrompt(input: Input) { if (event.type === "session.tool.progress") { const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID)) if (current) { - current.structured = event.data.structured - current.content = event.data.content + current.metadata = event.data.metadata } continue } @@ -360,9 +359,8 @@ export async function runNonInteractivePrompt(input: Input) { state: { status: "completed", input: current.input, - structured: event.data.structured, + metadata: event.data.metadata, content: event.data.content, - result: event.data.result, }, time: { created: current.timestamp, ran: current.timestamp, completed: time }, } @@ -379,9 +377,8 @@ export async function runNonInteractivePrompt(input: Input) { output: toolOutputText(current.tool, event.data.content), title: current.tool, metadata: { - structured: event.data.structured, + metadata: event.data.metadata, content: event.data.content, - result: event.data.result, providerCall: current.provider, providerResult: { executed: event.data.executed, state: event.data.resultState }, rawInput: current.raw, @@ -398,8 +395,8 @@ export async function runNonInteractivePrompt(input: Input) { const key = toolKey(event.data.assistantMessageID, event.data.callID) const current = tools.get(key) ?? fallbackTool(event) const error = event.data.error.message - const structured = event.data.metadata ?? current.structured - const content = event.data.content ?? current.content + const metadata = event.data.metadata ?? current.metadata + const content = event.data.content ?? nonEmptyToolContent(current.content) const tool: SessionMessageAssistantTool = { type: "tool", id: event.data.callID, @@ -410,10 +407,9 @@ export async function runNonInteractivePrompt(input: Input) { state: { status: "error", input: current.input, - structured, + metadata, content, error: event.data.error, - result: event.data.result, }, time: { created: current.timestamp, ran: current.timestamp, completed: time }, } @@ -429,7 +425,6 @@ export async function runNonInteractivePrompt(input: Input) { input: current.input, error, metadata: { - result: event.data.result, providerCall: current.provider, providerResult: { executed: event.data.executed, state: event.data.resultState }, rawInput: current.raw, @@ -441,15 +436,14 @@ export async function runNonInteractivePrompt(input: Input) { renderedTools.add(key) if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue if (!emit("tool_use", time, { part })) { - if (toolOutputText(current.tool, content).trim()) + if (content && toolOutputText(current.tool, content).trim()) await input.renderTool({ ...tool, state: { status: "completed", input: current.input, - structured, + metadata, content, - result: event.data.result, }, }) await input.renderToolError(tool) @@ -597,14 +591,14 @@ export async function runNonInteractivePrompt(input: Input) { input: item.state.input, output: toolOutputText(item.name, item.state.content), title: item.name, - metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result }, + metadata: { metadata: item.state.metadata, content: item.state.content }, time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp }, } : { status: "error", input: item.state.input, error: item.state.error.message, - metadata: { structured: item.state.structured, content: item.state.content, result: item.state.result }, + metadata: { metadata: item.state.metadata, content: item.state.content }, time: { start: item.time.ran ?? item.time.created, end: item.time.completed ?? timestamp }, }, } @@ -614,8 +608,16 @@ export async function runNonInteractivePrompt(input: Input) { await input.renderTool(item) continue } - if (toolOutputText(item.name, item.state.content).trim()) { - await input.renderTool({ ...item, state: { ...item.state, status: "completed" } }) + if (item.state.content && toolOutputText(item.name, item.state.content).trim()) { + await input.renderTool({ + ...item, + state: { + status: "completed", + input: item.state.input, + metadata: item.state.metadata, + content: item.state.content, + }, + }) } await input.renderToolError(item) UI.error(item.state.error.message) @@ -792,7 +794,7 @@ function fallbackTool(event: { assistantMessageID: event.data.assistantMessageID, tool: "tool", input: {}, - structured: {}, + metadata: {}, content: [], } } diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts index ded7b7020aa..42be5c45206 100644 --- a/packages/cli/test/acp/event-behavior.test.ts +++ b/packages/cli/test/acp/event-behavior.test.ts @@ -218,8 +218,7 @@ describe("acp event behavior", () => { sessionID: "ses_tools", assistantMessageID: "msg_tools", callID: "call_ok", - structured: { phase: 1 }, - content: [{ type: "text", text: "working" }], + metadata: { phase: 1 }, }), ) send( @@ -227,9 +226,8 @@ describe("acp event behavior", () => { sessionID: "ses_tools", assistantMessageID: "msg_tools", callID: "call_ok", - structured: { exit: 0 }, + metadata: { exit: 0 }, content: [{ type: "text", text: "done" }], - result: { code: 0 }, executed: true, }), ) @@ -255,8 +253,7 @@ describe("acp event behavior", () => { sessionID: "ses_tools", assistantMessageID: "msg_tools", callID: "call_fail", - structured: { bytes: 0 }, - content: [{ type: "text", text: "opening" }], + metadata: { bytes: 0 }, }), ) send( @@ -313,12 +310,10 @@ describe("acp event behavior", () => { locations: [{ path: resolve("/workspace", "sub") }], rawInput: { command: "printf done", workdir: "sub" }, }) - expect(updates[2]?.update).toMatchObject({ - content: [{ type: "content", content: { type: "text", text: "working" } }], - }) + expect(updates[2]?.update).not.toHaveProperty("content") expect(updates[3]?.update).toMatchObject({ content: [{ type: "content", content: { type: "text", text: "done" } }], - rawOutput: { structured: { exit: 0 }, result: { code: 0 } }, + rawOutput: { metadata: { exit: 0 } }, }) expect(updates[7]?.update).toMatchObject({ kind: "read", @@ -327,7 +322,7 @@ describe("acp event behavior", () => { { type: "content", content: { type: "text", text: "opening" } }, { type: "content", content: { type: "text", text: "not found" } }, ], - rawOutput: { structured: { bytes: 0 }, error: "not found" }, + rawOutput: { metadata: { bytes: 0 }, error: "not found" }, }) expect(response.stopReason).toBe("end_turn") } finally { @@ -379,7 +374,7 @@ describe("acp event behavior", () => { { type: "content", content: { type: "text", text: "done" } }, { type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } }, ], - rawOutput: { structured: { exit: 0 }, result: { code: 0 } }, + rawOutput: { metadata: { exit: 0 } }, }) expect(updates[8]?.update).toMatchObject({ toolCallId: "call_running", @@ -618,12 +613,11 @@ function replayFixtureMessages(): SessionMessageInfo[] { state: { status: "completed", input: { command: "printf done" }, - structured: { exit: 0 }, + metadata: { exit: 0 }, content: [ { type: "text", text: "done" }, { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" }, ], - result: { code: 0 }, }, }, { @@ -634,8 +628,7 @@ function replayFixtureMessages(): SessionMessageInfo[] { state: { status: "running", input: { command: "pwd" }, - structured: {}, - content: [{ type: "text", text: "/workspace" }], + metadata: {}, }, }, { @@ -646,7 +639,7 @@ function replayFixtureMessages(): SessionMessageInfo[] { state: { status: "error", input: { filePath: "/workspace/missing.ts" }, - structured: { bytes: 0 }, + metadata: { bytes: 0 }, content: [{ type: "text", text: "partial" }], error: { type: "tool.error", message: "failed hard" }, }, @@ -679,7 +672,7 @@ function replayToolMessage(id: string) { state: { status: "completed", input: { command: "printf done" }, - structured: { exit: 0 }, + metadata: { exit: 0 }, content: [{ type: "text", text: "done" }], }, }, diff --git a/packages/cli/test/acp/permission-behavior.test.ts b/packages/cli/test/acp/permission-behavior.test.ts index 355ff5bb410..df7de586fdd 100644 --- a/packages/cli/test/acp/permission-behavior.test.ts +++ b/packages/cli/test/acp/permission-behavior.test.ts @@ -28,7 +28,7 @@ describe("acp permission behavior", () => { cwd: "/workspace", toolName: "edit", toolInput: { filePath: "/workspace/file.ts" }, - structured: {}, + metadata: {}, }) expect(writes).toEqual([]) @@ -193,7 +193,7 @@ describe("acp permission behavior", () => { sessionID: "ses_edit", assistantMessageID: "msg_edit", callID: "call_edit", - structured: { files: [{ file: "file.ts" }], replacements: 1 }, + metadata: { files: [{ file: "file.ts" }], replacements: 1 }, content: [{ type: "text", text: "edited" }], executed: true, }), @@ -286,7 +286,7 @@ describe("acp permission behavior", () => { sessionID: "ses_patch", assistantMessageID: "msg_patch", callID: "call_patch", - structured: { files: [{ file: "first.ts" }, { file: "second.ts" }] }, + metadata: { files: [{ file: "first.ts" }, { file: "second.ts" }] }, content: [{ type: "text", text: "patched" }], executed: true, }), diff --git a/packages/cli/test/acp/tool.test.ts b/packages/cli/test/acp/tool.test.ts index da90fb7c053..0095b57ce1c 100644 --- a/packages/cli/test/acp/tool.test.ts +++ b/packages/cli/test/acp/tool.test.ts @@ -63,7 +63,7 @@ describe("acp tools", () => { { type: "file", mime: "image/png", name: "image.png", uri: `data:image/png;base64,${image}` }, { type: "file", mime: "text/plain", name: "note.txt", uri: "data:text/plain;base64,bm90ZQ==" }, ], - structured: {}, + metadata: {}, }).content, ).toEqual([ { @@ -93,7 +93,7 @@ describe("acp tools", () => { content: "created", }, content: [{ type: "text", text: "wrote /tmp/file.ts" }], - structured: {}, + metadata: {}, }).content, ).toEqual([ { @@ -103,20 +103,22 @@ describe("acp tools", () => { ]) }) - test("uses clean structured read content instead of model-facing formatting", () => { + test("unwraps read's JSON page envelope instead of showing model-facing formatting", () => { expect( completedToolUpdate({ toolCallId: "tool-read", toolName: "read", input: { path: "/tmp/file.ts" }, - content: [{ type: "text", text: "1: first\n2: second" }], - structured: { - type: "text-page", - content: "first\nsecond", - mime: "text/plain", - offset: 1, - truncated: false, - }, + content: [ + { + type: "text", + text: JSON.stringify( + { type: "text-page", content: "first\nsecond", mime: "text/plain", offset: 1, truncated: false }, + null, + 2, + ), + }, + ], }).content, ).toEqual([{ type: "content", content: { type: "text", text: "first\nsecond" } }]) @@ -125,13 +127,17 @@ describe("acp tools", () => { toolCallId: "tool-list", toolName: "read", input: { path: "/tmp" }, - content: [], - structured: { - entries: [ - { path: "a.ts", type: "file" }, - { path: "src", type: "directory" }, - ], - }, + content: [ + { + type: "text", + text: JSON.stringify({ + entries: [ + { path: "a.ts", type: "file" }, + { path: "src", type: "directory" }, + ], + }), + }, + ], }).content, ).toEqual([{ type: "content", content: { type: "text", text: "a.ts\nsrc" } }]) }) @@ -171,7 +177,7 @@ describe("acp tools", () => { newString: "after", }, content: [{ type: "text", text: "Edit applied successfully." }], - structured: { output: "Edit applied successfully." }, + metadata: { output: "Edit applied successfully." }, }), ).toEqual({ toolCallId: "tool-1", @@ -189,7 +195,7 @@ describe("acp tools", () => { }, ], rawOutput: { - structured: { output: "Edit applied successfully." }, + metadata: { output: "Edit applied successfully." }, }, }) }) @@ -209,7 +215,7 @@ describe("acp tools", () => { }) }) - test("builds completed raw output with structured data and optional result", () => { + test("builds completed raw output with optional metadata", () => { const attachments = [ { type: "file", @@ -225,12 +231,10 @@ describe("acp tools", () => { toolName: "read", input: {}, content: [], - structured: { output: "done", metadata: { exit: 0 }, attachments }, - result: "done", + metadata: { output: "done", metadata: { exit: 0 }, attachments }, }).rawOutput, ).toEqual({ - structured: { output: "done", metadata: { exit: 0 }, attachments }, - result: "done", + metadata: { output: "done", metadata: { exit: 0 }, attachments }, }) expect( @@ -239,9 +243,8 @@ describe("acp tools", () => { toolName: "read", input: {}, content: [], - structured: { output: "done" }, }).rawOutput, - ).toEqual({ structured: { output: "done" } }) + ).toEqual({}) }) test("extracts image attachments only from data URLs", () => { @@ -255,7 +258,7 @@ describe("acp tools", () => { { type: "file", mime: "image/png", uri: "https://example.com/image.png" }, { type: "file", mime: "text/plain", uri: "data:text/plain;base64,BBBB" }, ], - structured: {}, + metadata: {}, }).content, ).toEqual([ { @@ -272,7 +275,7 @@ describe("acp tools", () => { toolName: "read", input: { filePath: "/tmp/a" }, content: [{ type: "text", text: "partial output" }], - structured: { path: "/tmp/a" }, + metadata: { path: "/tmp/a" }, error: "failed", }), ).toEqual({ @@ -286,7 +289,7 @@ describe("acp tools", () => { { type: "content", content: { type: "text", text: "partial output" } }, { type: "content", content: { type: "text", text: "failed" } }, ], - rawOutput: { structured: { path: "/tmp/a" }, error: "failed" }, + rawOutput: { metadata: { path: "/tmp/a" }, error: "failed" }, }) }) }) diff --git a/packages/cli/test/import-boundaries.test.ts b/packages/cli/test/import-boundaries.test.ts index 8b189ce2c03..3634aa5aed9 100644 --- a/packages/cli/test/import-boundaries.test.ts +++ b/packages/cli/test/import-boundaries.test.ts @@ -23,7 +23,12 @@ describe("CLI frontend import boundaries", () => { expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"]) expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"]) - expect(Object.keys(tool).sort()).toEqual(["toolInlineInfo", "toolOutputText"]) + expect(Object.keys(tool).sort()).toEqual([ + "nonEmptyToolContent", + "readDisplayText", + "toolInlineInfo", + "toolOutputText", + ]) expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([]) }) diff --git a/packages/cli/test/run/noninteractive.test.ts b/packages/cli/test/run/noninteractive.test.ts index f0a680061f2..8527bcaa57a 100644 --- a/packages/cli/test/run/noninteractive.test.ts +++ b/packages/cli/test/run/noninteractive.test.ts @@ -134,15 +134,14 @@ function failedTool(inputID: string): V2Event[] { sessionID: "ses_1", assistantMessageID: "msg_failed_tool", callID: "call_failed_tool", - structured: { checkpoint: 1 }, - content: [{ type: "text", text: "partial output" }], + metadata: { checkpoint: 1 }, }, }, { id: "evt_failed_tool_terminal", created: 4, type: "session.tool.failed", - durable: { aggregateID: "ses_1", seq: 4, version: 1 }, + durable: { aggregateID: "ses_1", seq: 4, version: 2 }, data: { sessionID: "ses_1", assistantMessageID: "msg_failed_tool", @@ -190,12 +189,12 @@ function successfulGrep(inputID: string): V2Event[] { id: "evt_grep_success", created: 3, type: "session.tool.success", - durable: { aggregateID: "ses_1", seq: 3, version: 1 }, + durable: { aggregateID: "ses_1", seq: 3, version: 2 }, data: { sessionID: "ses_1", assistantMessageID: "msg_grep", callID: "call_grep", - structured: { matches: 2 }, + metadata: { matches: 2 }, content: [{ type: "text", text }], executed: false, }, @@ -258,9 +257,7 @@ async function run(input: { spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise) spyOn(sdk.message, "list").mockImplementation(() => ok({ - data: input.messages?.(promptID) ?? [ - { id: promptID, type: "user", text: "hello", time: { created: 1 } }, - ], + data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }], cursor: {}, }), ) @@ -316,7 +313,7 @@ afterEach(() => { }) describe("runNonInteractivePrompt", () => { - test("keeps formatted tool output and compact structured metadata in JSON", async () => { + test("keeps formatted tool output and compact tool metadata in JSON", async () => { const output = await capture({ format: "json", turn: successfulGrep }) const events = output.stdout .split("\n") @@ -332,13 +329,13 @@ describe("runNonInteractivePrompt", () => { status: "completed", output: expect.stringContaining("Found 2 matches"), metadata: { - structured: { matches: 2 }, + metadata: { matches: 2 }, content: [{ type: "text", text: expect.stringContaining("/src/a.ts") }], }, }, }, }) - expect(events[0].part.state.metadata.structured).toEqual({ matches: 2 }) + expect(events[0].part.state.metadata.metadata).toEqual({ matches: 2 }) expect(events[0].part.state.metadata.result).toBeUndefined() }) @@ -534,7 +531,7 @@ describe("runNonInteractivePrompt", () => { id: "call_failed_tool", state: { status: "completed", - structured: { checkpoint: 1 }, + metadata: { checkpoint: 1 }, content: [{ type: "text", text: "partial output" }], }, }, @@ -544,7 +541,7 @@ describe("runNonInteractivePrompt", () => { id: "call_failed_tool", state: { status: "error", - structured: { checkpoint: 1 }, + metadata: { checkpoint: 1 }, content: [{ type: "text", text: "partial output" }], error: { message: "tool failed" }, }, @@ -574,7 +571,7 @@ describe("runNonInteractivePrompt", () => { }, }) expect(events[0].part.state.output).toBeUndefined() - expect(events[0].part.state.metadata.structured).toBeUndefined() + expect(events[0].part.state.metadata.metadata).toBeUndefined() expect(events[0].part.state.metadata.content).toBeUndefined() expect(output.stderr).toBe("") }) diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts index 791a0131ac2..15bcc8490a3 100644 --- a/packages/cli/vite.node.config.ts +++ b/packages/cli/vite.node.config.ts @@ -75,6 +75,10 @@ export const define = sdk.Plugin.define` const effectPluginModule = promisePluginModule .replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect") .replace("Promise plugin", "Effect plugin") + const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")] +if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable") +export const Tool = sdk.Tool +export const make = sdk.Tool.make` const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")] if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable") export const Tool = sdk.Tool @@ -83,10 +87,8 @@ export const RegistrationError = sdk.Tool.RegistrationError export const make = sdk.Tool.make export const validateName = sdk.Tool.validateName export const registrationEntries = sdk.Tool.registrationEntries -export const withPermission = sdk.Tool.withPermission -export const permission = sdk.Tool.permission -export const definition = sdk.Tool.definition -export const settle = sdk.Tool.settle` +export const validateNamespace = sdk.Tool.validateNamespace +export const toLLMDefinition = sdk.Tool.toLLMDefinition` return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")} import __cjs_mod__ from "node:module" import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs" @@ -100,6 +102,7 @@ const require = __cjs_mod__.createRequire(import.meta.url) const __ocPluginModules = ${JSON.stringify({ "@opencode-ai/plugin/v2": "opencode:plugin-v2", "@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin", + "@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool", "@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect", "@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin", "@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool", @@ -107,6 +110,7 @@ const __ocPluginModules = ${JSON.stringify({ const __ocPluginSources = ${JSON.stringify({ "opencode:plugin-v2": promiseModule, "opencode:plugin-v2-plugin": promisePluginModule, + "opencode:plugin-v2-tool": promiseToolModule, "opencode:plugin-v2-effect": effectModule, "opencode:plugin-v2-effect-plugin": effectPluginModule, "opencode:plugin-v2-effect-tool": effectToolModule, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index a08c7b32468..a1b2367c8b4 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -104,6 +104,12 @@ export type SessionMessageProviderState = { [x: string]: JsonValue } export type SessionMessageToolStateStreaming = { status: "streaming"; input: string } +export type SessionMessageToolStateRunning = { + status: "running" + input: { [x: string]: JsonValue } + metadata: { [x: string]: JsonValue } +} + export type ToolTextContent = { type: "text"; text: string } export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string } @@ -918,6 +924,15 @@ export type SessionToolInputDelta = { data: { sessionID: string; assistantMessageID: string; callID: string; delta: string } } +export type SessionToolProgress = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.tool.progress" + location?: LocationRef + data: { sessionID: string; assistantMessageID: string; callID: string; metadata: { [x: string]: JsonValue } } +} + export type SessionCompactionDelta = { id: string created: number @@ -1809,28 +1824,19 @@ export type SessionPendingUserData1 = { metadata?: { [x: string]: any } } -export type SessionMessageToolStateRunning = { - status: "running" - input: { [x: string]: JsonValue } - structured: { [x: string]: JsonValue } - content: Array -} - export type SessionMessageToolStateCompleted = { status: "completed" input: { [x: string]: JsonValue } - content: Array - structured: { [x: string]: JsonValue } - result?: JsonValue + content: [LLMToolContent, ...Array] + metadata?: { [x: string]: JsonValue } } export type SessionMessageToolStateError = { status: "error" input: { [x: string]: JsonValue } - content: Array - structured: { [x: string]: JsonValue } error: SessionStructuredError - result?: JsonValue + content?: [LLMToolContent, ...Array] + metadata?: { [x: string]: JsonValue } } export type SessionToolSuccess = { @@ -1838,15 +1844,14 @@ export type SessionToolSuccess = { created: number metadata?: { [x: string]: any } type: "session.tool.success" - durable: { aggregateID: string; seq: number; version: 1 } + durable: { aggregateID: string; seq: number; version: 2 } location?: LocationRef data: { sessionID: string assistantMessageID: string callID: string - structured: { [x: string]: any } - content: Array - result?: any + content: [LLMToolContent, ...Array] + metadata?: { [x: string]: JsonValue } executed: boolean resultState?: SessionMessageProviderState6 } @@ -1857,7 +1862,7 @@ export type SessionToolFailed = { created: number metadata?: { [x: string]: any } type: "session.tool.failed" - durable: { aggregateID: string; seq: number; version: 1 } + durable: { aggregateID: string; seq: number; version: 2 } location?: LocationRef data: { sessionID: string @@ -1865,28 +1870,12 @@ export type SessionToolFailed = { callID: string error: SessionStructuredError content?: [LLMToolContent, ...Array] - metadata?: { [x: string]: any } - result?: any + metadata?: { [x: string]: JsonValue } executed: boolean resultState?: SessionMessageProviderState7 } } -export type SessionToolProgress = { - id: string - created: number - metadata?: { [x: string]: any } - type: "session.tool.progress" - location?: LocationRef - data: { - sessionID: string - assistantMessageID: string - callID: string - structured: { [x: string]: any } - content: Array - } -} - export type SessionMessageCompaction = | SessionMessageCompactionRunning | SessionMessageCompactionCompleted diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 09ba57346eb..923da8d441d 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -33,7 +33,7 @@ const lookupOrder = Tool.make({ description: "Look up an order by ID", input: Schema.Struct({ id: Schema.String }), output: Schema.Struct({ id: Schema.String, status: Schema.String }), - run: ({ id }) => Effect.succeed({ id, status: "open" }), + execute: ({ id }) => Effect.succeed({ id, status: "open" }), }) const runtime = CodeMode.make({ @@ -55,10 +55,10 @@ const result = await Effect.runPromise( ### `Tool.make` `input` and `output` accept either an Effect Schema or a render-only JSON Schema document. Effect Schema input is -decoded before `run`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas only -shape the model-visible signature. Without `output`, the signature uses `Promise`. +decoded before `execute`; Effect Schema output is decoded and safely copied before the program sees it. JSON Schemas +only shape the model-visible signature. Without `output`, the signature uses `Promise`. -Descriptions and schemas are model-visible contracts. Authorization belongs in `run`. +Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`. Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose `tools.issues.list(...)`. Other characters use bracket notation, such as diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 0d989595ec8..4b8b9001107 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -188,7 +188,7 @@ ultimate source of truth. first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields and a JavaScript `this` receiver remain outside the supported object/function model. - [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the - last definition supplied for a canonical path wins. + last tool supplied for a canonical path wins. - [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys. - [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with `undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts index af2eccf9b85..a3f789868ef 100644 --- a/packages/codemode/src/interpreter/execute.ts +++ b/packages/codemode/src/interpreter/execute.ts @@ -45,7 +45,7 @@ export const executeWithLimits = const program = parseProgram(options.code) const promises = new PromiseRuntime>(scope) const interpreter = new Interpreter>( - tools.invoke, + tools.execute, tools.search, tools.keys, promises, diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 3af385d5130..2659533346a 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -271,7 +271,10 @@ const promiseResolutionNode: AstNode = { type: "PromiseResolution" } export class Interpreter { private scopes: ScopeStack - private readonly invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect + private readonly executeTool: ( + path: ReadonlyArray, + args: Array, + ) => Effect.Effect private readonly invokeSearch: (args: Array) => Effect.Effect private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray private readonly logs: Array @@ -286,7 +289,7 @@ export class Interpreter { } constructor( - invokeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, + executeTool: (path: ReadonlyArray, args: Array) => Effect.Effect, invokeSearch: (args: Array) => Effect.Effect, toolKeys: (path: ReadonlyArray) => ReadonlyArray, promises: PromiseRuntime, @@ -294,7 +297,7 @@ export class Interpreter { ) { const globalScope = new Map() this.scopes = new ScopeStack([globalScope]) - this.invokeTool = invokeTool + this.executeTool = executeTool this.invokeSearch = invokeSearch this.toolKeys = toolKeys this.logs = logs @@ -369,7 +372,7 @@ export class Interpreter { path: ReadonlyArray, args: Array, ): Effect.Effect { - return this.createPromise(Effect.suspend(() => this.invokeTool(path, args))) + return this.createPromise(Effect.suspend(() => this.executeTool(path, args))) } private createPromise(effect: Effect.Effect): Effect.Effect { @@ -2079,7 +2082,7 @@ export class Interpreter { } private invokeFunction(fn: CodeModeFunction, args: Array): Effect.Effect { - const invocation = new Interpreter(this.invokeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs) + const invocation = new Interpreter(this.executeTool, this.invokeSearch, this.toolKeys, this.promises, this.logs) invocation.scopes = new ScopeStack([...fn.capturedScopes, new Map()]) const run = Effect.gen(function* () { // Seed all parameters first so defaults cannot fall through to same-named outer bindings. diff --git a/packages/codemode/src/openapi/index.ts b/packages/codemode/src/openapi/index.ts index 5ea688129e1..1da5779cb44 100644 --- a/packages/codemode/src/openapi/index.ts +++ b/packages/codemode/src/openapi/index.ts @@ -1,5 +1,5 @@ import { HttpClient } from "effect/unstable/http" -import { make, type Definition } from "../tool.js" +import { make, type Tool } from "../tool.js" import { invoke } from "./runtime.js" import { componentDefinitions, @@ -108,7 +108,7 @@ export const fromSpec = (options: Options): Result => { description: operation.description ?? operation.summary ?? `${operation.method} ${path}`, input: inputSchema(input.fields, requestDefinitions), output: output.value, - run: (input) => invoke(plan, input), + execute: (input) => invoke(plan, input), }), ) } @@ -117,16 +117,16 @@ export const fromSpec = (options: Options): Result => { return { tools, skipped } } -const setTool = (tools: Tools, path: ReadonlyArray, definition: Definition): void => { +const setTool = (tools: Tools, path: ReadonlyArray, tool: Tool): void => { const [head, ...rest] = path if (head === undefined) return if (rest.length === 0) { - tools[head] = definition + tools[head] = tool return } const child = tools[head] if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") { tools[head] = Object.create(null) as Tools } - setTool(tools[head] as Tools, rest, definition) + setTool(tools[head] as Tools, rest, tool) } diff --git a/packages/codemode/src/openapi/types.ts b/packages/codemode/src/openapi/types.ts index 252f49d86c7..d1d4e5b49a5 100644 --- a/packages/codemode/src/openapi/types.ts +++ b/packages/codemode/src/openapi/types.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { HttpClient } from "effect/unstable/http" -import type { Definition, JsonSchema } from "../tool.js" +import type { Tool, JsonSchema } from "../tool.js" /** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */ export type Document = Record @@ -58,7 +58,7 @@ export type Skipped = { readonly reason: string } -export type Tools = { [name: string]: Definition | Tools } +export type Tools = { [name: string]: Tool | Tools } export type Result = { /** Namespaced tools; the host places them under a key in its `tools` object. */ diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index febc2ba5860..389d3af2760 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -8,7 +8,7 @@ import { inputTypeScript, outputTypeScript, } from "./tool-schema.js" -import { isDefinition as isToolDefinition, type Definition } from "./tool.js" +import { isTool, type Tool } from "./tool.js" import type { Tools } from "./tools.js" import { CodeModeDate, @@ -28,7 +28,7 @@ type ServicesOf> = Depth["length"] exten ? never : T extends { readonly _tag: "CodeModeTool" - readonly run: (input: unknown) => Effect.Effect + readonly execute: (input: unknown) => Effect.Effect } ? R : T extends object @@ -118,8 +118,6 @@ export class ToolRuntimeError extends Error { } } -const isDefinition = (value: Definition | Tools): value is Definition => isToolDefinition(value) - const runHost = (effect: Effect.Effect): Effect.Effect => effect.pipe( Effect.catchCause((cause) => { @@ -286,9 +284,9 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => { return value } -// Dots in tool names are namespace separators; the last definition for a canonical path wins. +// Dots in tool names are namespace separators; the last tool for a canonical path wins. type ToolNode = { - definition?: Definition + tool?: Tool readonly children: Map> } @@ -303,7 +301,7 @@ const toolTrie = (tools: Tools): ToolNode => { current.children.set(segment, child) current = child } - if (isDefinition(value)) current.definition = value + if (isTool(value)) current.tool = value else insert(current, value) } } @@ -314,25 +312,25 @@ const toolTrie = (tools: Tools): ToolNode => { const canonicalSegments = (path: ReadonlyArray): ReadonlyArray => path.flatMap((segment) => segment.split(".")) -const definitions = ( +const flattenTools = ( node: ToolNode, path: ReadonlyArray = [], -): Array<{ path: string; definition: Definition }> => [ - ...(node.definition === undefined ? [] : [{ path: path.join("."), definition: node.definition }]), - ...Array.from(node.children, ([name, child]) => definitions(child, [...path, name])).flat(), +): Array<{ path: string; tool: Tool }> => [ + ...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]), + ...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(), ] -const describeDefinition = (path: string, definition: Definition): ToolDescription => ({ +const describeTool = (path: string, tool: Tool): ToolDescription => ({ path, - description: definition.description, - signature: `${toolExpression(path)}(input: ${inputTypeScript(definition, true)}): Promise<${outputTypeScript(definition, true)}>`, + description: tool.description, + signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`, }) -const visibleDefinitions = (tools: Tools) => - definitions(toolTrie(tools)).map(({ path, definition }) => ({ +const visibleTools = (tools: Tools) => + flattenTools(toolTrie(tools)).map(({ path, tool }) => ({ path, - definition, - description: describeDefinition(path, definition), + tool, + description: describeTool(path, tool), })) export type DiscoveryPlan = { @@ -361,12 +359,12 @@ const termForms = (term: string): Array => { return forms } -const makeSearchTool = (searchIndex: ReadonlyArray): Definition => ({ +const makeSearchTool = (searchIndex: ReadonlyArray): Tool => ({ _tag: "CodeModeTool", description: "Search available tools", input: SearchInput, output: SearchOutput, - run: (input) => + execute: (input) => Effect.sync(() => { const request = input as typeof SearchInput.Type const query = request.query ?? "" @@ -422,8 +420,8 @@ const makeSearchTool = (searchIndex: ReadonlyArray): Definition => }) const searchSignature = (() => { - const definition = makeSearchTool([]) - return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}` + const tool = makeSearchTool([]) + return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}` })() const catalogLine = (tool: ToolDescription) => { @@ -432,13 +430,13 @@ const catalogLine = (tool: ToolDescription) => { return description === "" ? ` - ${tool.signature}` : ` - ${tool.signature} // ${description}` } -const toSearchEntry = (path: string, definition: Definition, description: ToolDescription): SearchEntry => ({ +const toSearchEntry = (path: string, tool: Tool, description: ToolDescription): SearchEntry => ({ description, namespace: path.split(".", 1)[0]!, searchText: [ path, - definition.description, - ...inputProperties(definition).flatMap(({ name, description: property }) => + tool.description, + ...inputProperties(tool).flatMap(({ name, description: property }) => property === undefined ? [name] : [name, property], ), ] @@ -447,14 +445,14 @@ const toSearchEntry = (path: string, definition: Definition, description: }) export const searchIndex = (tools: Tools): ReadonlyArray => - visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description)) + visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description)) // Budget signatures round-robin so every namespace remains visible. export const prepare = (tools: Tools, catalogBudget = defaultCatalogBudget): DiscoveryPlan => { if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) { throw new RangeError("discovery.catalogBudget must be a non-negative safe integer") } - const visible = visibleDefinitions(tools) + const visible = visibleTools(tools) const described = visible.map(({ description }) => description) const namespaces = new Map>() @@ -589,7 +587,7 @@ export const prepare = (tools: Tools, catalogBudget = defaultCatalogBudget return { catalog: described, instructions: lines.join("\n"), - searchIndex: visible.map(({ path, definition, description }) => toSearchEntry(path, definition, description)), + searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)), } } @@ -605,7 +603,7 @@ const namespaceKeys = (root: ToolNode, path: ReadonlyArray): Reado return Array.from(node.children.keys()) } -const resolve = (root: ToolNode, path: ReadonlyArray): Definition => { +const resolve = (root: ToolNode, path: ReadonlyArray): Tool => { const segments = canonicalSegments(path) const node = lookup(root, segments) if (node === undefined) { @@ -613,16 +611,16 @@ const resolve = (root: ToolNode, path: ReadonlyArray): Definition< "Use search({ query }) to find available described tools.", ]) } - if (node.definition === undefined) { + if (node.tool === undefined) { throw new ToolRuntimeError("UnknownTool", `Tool '${segments.join(".")}' is not callable.`) } - return node.definition + return node.tool } export type ToolRuntime = { readonly root: ToolReference readonly calls: Array - readonly invoke: (path: ReadonlyArray, args: Array) => Effect.Effect + readonly execute: (path: ReadonlyArray, args: Array) => Effect.Effect readonly search: (args: Array) => Effect.Effect readonly keys: (path: ReadonlyArray) => ReadonlyArray } @@ -676,7 +674,7 @@ export const make = ( return calls.length - 1 }).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void)) - const invokeDefinition = (name: string, tool: Definition, externalArgs: Array) => + const executeTool = (name: string, tool: Tool, externalArgs: Array) => Effect.gen(function* () { if (externalArgs.length !== 1) throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`) @@ -688,7 +686,7 @@ export const make = ( const index = yield* recordAndObserve(name, input) return yield* observeEnd( Effect.gen(function* () { - const raw = yield* runHost(Effect.suspend(() => tool.run(input))) + const raw = yield* runHost(Effect.suspend(() => tool.execute(input))) const result = yield* Effect.try({ try: () => decodeToolOutput(tool, raw), catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`), @@ -705,18 +703,18 @@ export const make = ( keys: (path) => namespaceKeys(root, path), search: (args) => Effect.suspend(() => - invokeDefinition( + executeTool( "search", searchTool, args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"), "json")), ), ), - invoke: (path, args) => + execute: (path, args) => Effect.gen(function* () { const name = canonicalSegments(path).join(".") const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`), "json")) const tool = resolve(root, path) - return yield* invokeDefinition(name, tool, externalArgs) + return yield* executeTool(name, tool, externalArgs) }), } } diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index d8b48dc5485..52e1cd4033d 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -1,5 +1,5 @@ import { JsonPointer, Schema } from "effect" -import type { Definition, JsonSchema, SchemaType } from "./tool.js" +import type { Tool, JsonSchema, SchemaType } from "./tool.js" const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder & Schema.Top => Schema.isSchema(schema) @@ -192,16 +192,16 @@ export type InputProperty = { readonly required: boolean } -export const inputProperties = (definition: Definition): Array => { +export const inputProperties = (tool: Tool): Array => { try { - const document = isEffectSchema(definition.input) - ? (Schema.toJsonSchemaDocument(definition.input) as { + const document = isEffectSchema(tool.input) + ? (Schema.toJsonSchemaDocument(tool.input) as { readonly schema: JsonSchema readonly definitions?: Readonly> }) : { - schema: definition.input, - definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) }, + schema: tool.input, + definitions: { ...(tool.input.definitions ?? {}), ...(tool.input.$defs ?? {}) }, } const definitions = document.definitions ?? {} let schema = document.schema @@ -223,22 +223,22 @@ export const inputProperties = (definition: Definition): Array(definition: Definition, pretty = false): string => - isEffectSchema(definition.input) - ? toTypeScript(definition.input, false, pretty) - : jsonSchemaToTypeScript(definition.input, pretty) +export const inputTypeScript = (tool: Tool, pretty = false): string => + isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty) -export const outputTypeScript = (definition: Definition, pretty = false): string => - definition.output === undefined - ? "unknown" - : isEffectSchema(definition.output) - ? toTypeScript(definition.output, true, pretty) - : jsonSchemaToTypeScript(definition.output, pretty) +export const outputTypeScript = (tool: Tool, pretty = false): string => + tool.output === undefined + ? "void" + : isEffectSchema(tool.output) + ? toTypeScript(tool.output, true, pretty) + : jsonSchemaToTypeScript(tool.output, pretty) -export const decodeInput = (definition: Definition, value: unknown): unknown => - isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value +export const decodeInput = (tool: Tool, value: unknown): unknown => + isEffectSchema(tool.input) ? Schema.decodeUnknownSync(tool.input)(value) : value -export const decodeOutput = (definition: Definition, value: unknown): unknown => - definition.output !== undefined && isEffectSchema(definition.output) - ? Schema.decodeUnknownSync(definition.output)(value) - : value +export const decodeOutput = (tool: Tool, value: unknown): unknown => + tool.output === undefined + ? undefined + : isEffectSchema(tool.output) + ? Schema.decodeUnknownSync(tool.output)(value) + : value diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index e75fa7ba260..d44e3d0f4e2 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -29,29 +29,29 @@ export type JsonSchema = { /** Either a validating Effect Schema or a render-only JSON Schema document. */ export type SchemaType = Schema.Decoder | JsonSchema -/** Schema-backed tool definition exposed through CodeMode's `tools` object. */ -export type Definition = { +/** Executable tool tool exposed through CodeMode's `tools` object. */ +export type Tool = { readonly _tag: "CodeModeTool" readonly description: string readonly input: SchemaType readonly output: SchemaType | undefined - readonly run: (input: unknown) => Effect.Effect + readonly execute: (input: unknown) => Effect.Effect } type InputType = S extends Schema.Decoder ? S["Type"] : unknown -type ResultType = S extends Schema.Decoder ? S["Encoded"] : unknown +type ResultType = S extends undefined ? void : S extends Schema.Decoder ? S["Encoded"] : unknown -/** Options for defining one CodeMode tool. */ +/** Options for declaring one CodeMode tool. */ export type Options = { readonly description: string readonly input: I readonly output?: O - readonly run: (input: InputType) => Effect.Effect, unknown, R> + readonly execute: (input: InputType) => Effect.Effect, unknown, R> } -// Object.hasOwn: an inherited _tag must not classify a namespace as a Definition. -export const isDefinition = (value: unknown): value is Definition => +// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool. +export const isTool = (value: unknown): value is Tool => typeof value === "object" && value !== null && "_tag" in value && @@ -59,18 +59,18 @@ export const isDefinition = (value: unknown): value is Definition value._tag === "CodeModeTool" /** - * Defines one schema-described tool available to a CodeMode program through `tools.*`. + * Declares one schema-described tool available to a CodeMode program through `tools.*`. * * Effect Schemas validate values; JSON Schemas only shape the model-visible signature. - * Without `output`, results are exposed as `unknown`. Hosts remain responsible for authorization + * Without `output`, results are exposed as `void`. Hosts remain responsible for authorization * and durable side effects. */ export const make = ( options: Options, -): Definition => ({ +): Tool => ({ _tag: "CodeModeTool", description: options.description, input: options.input, output: options.output, - run: (input) => options.run(input as InputType), + execute: (input) => options.execute(input as InputType), }) diff --git a/packages/codemode/src/tools.ts b/packages/codemode/src/tools.ts index 04e36dcef22..8c9759fb338 100644 --- a/packages/codemode/src/tools.ts +++ b/packages/codemode/src/tools.ts @@ -1,5 +1,5 @@ -import type { Definition } from "./tool.js" +import type { Tool } from "./tool.js" export type Tools = { - readonly [name: string]: Definition | Tools + readonly [name: string]: Tool | Tools } diff --git a/packages/codemode/test/callbacks.test.ts b/packages/codemode/test/callbacks.test.ts index 3c643917185..f257b732c0c 100644 --- a/packages/codemode/test/callbacks.test.ts +++ b/packages/codemode/test/callbacks.test.ts @@ -27,7 +27,7 @@ const echo = Tool.make({ description: "Echo the input", input: Schema.Struct({ id: Schema.Number }), output: Schema.Number, - run: (input: { id: number }) => Effect.succeed(input.id), + execute: (input: { id: number }) => Effect.succeed(input.id), }) const withTool = (code: string) => Effect.runPromise(CodeMode.make({ tools: { host: { echo } } }).execute(code)) const toolError = async (code: string) => { diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 8699daf07da..9d5db0ba583 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Cause, Effect, Schema } from "effect" import { CodeMode, Tool, toolError } from "../src/index.js" -const run = (tool: Tool.Definition) => +const run = (tool: Tool.Tool) => Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})")) class UnsafeHostError extends Schema.TaggedErrorClass()("UnsafeHostError", { @@ -16,7 +16,7 @@ describe("CodeMode host failure boundary", () => { description: "Fail safely", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.fail(toolError("Authorized request was refused")), + execute: () => Effect.fail(toolError("Authorized request was refused")), }), ) @@ -32,7 +32,7 @@ describe("CodeMode host failure boundary", () => { description: "Fail safely", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.fail(toolError("File not found: /tmp/report.json")), + execute: () => Effect.fail(toolError("File not found: /tmp/report.json")), }), ) @@ -52,7 +52,7 @@ describe("CodeMode host failure boundary", () => { description: "Fail internally", input: Schema.Struct({}), output: Schema.String, - run: () => failure, + execute: () => failure, }), ) @@ -71,7 +71,7 @@ describe("CodeMode host failure boundary", () => { description: "Return invalid output", input: Schema.Struct({}), output: Schema.Struct({ safe: Schema.String }), - run: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }), + execute: () => Effect.succeed({ safe: 1, secret } as unknown as { readonly safe: string }), }), ) @@ -88,7 +88,7 @@ describe("CodeMode host failure boundary", () => { description: "Return hostile output", input: Schema.Struct({}), output: Schema.Unknown, - run: () => + execute: () => Effect.succeed( new Proxy( {}, @@ -118,7 +118,7 @@ describe("CodeMode host failure boundary", () => { description: "Refuse", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.fail(toolError("Refused")), + execute: () => Effect.fail(toolError("Refused")), }), }, }, @@ -145,7 +145,7 @@ describe("CodeMode host failure boundary", () => { description: "Interrupt", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.interrupt, + execute: () => Effect.interrupt, }), }, }, @@ -166,7 +166,7 @@ describe("CodeMode tool-call observation", () => { description: "Look up a value", input: Schema.Struct({ query: Schema.String }), output: Schema.String, - run: ({ query }) => Effect.succeed(query), + execute: ({ query }) => Effect.succeed(query), }) const result = await Effect.runPromise( @@ -189,7 +189,7 @@ describe("CodeMode tool-call observation", () => { description: "Look up a value", input: Schema.Struct({ query: Schema.String }), output: Schema.String, - run: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)), + execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)), }) const runtime = CodeMode.make({ @@ -430,7 +430,7 @@ describe("CodeMode schema flexibility", () => { properties: { id: { type: "string" }, count: { type: "number" } }, required: ["id"], }, - run: (input) => + execute: (input) => Effect.sync(() => { observed.push(input) return { echoed: input } @@ -442,14 +442,14 @@ describe("CodeMode schema flexibility", () => { { path: "adapter.call", description: "Call an adapter-described tool", - signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise", + signature: "tools.adapter.call(input: {\n id: string,\n count?: number,\n}): Promise", }, ]) // JSON Schema is render-only: mistyped input passes through unvalidated. const result = await Effect.runPromise(runtime.execute(`return await tools.adapter.call({ id: 42 })`)) expect(result.ok).toBe(true) - if (result.ok) expect(result.value).toStrictEqual({ echoed: { id: 42 } }) + if (result.ok) expect(result.value).toBeNull() expect(observed).toStrictEqual([{ id: 42 }]) }) @@ -458,7 +458,7 @@ describe("CodeMode schema flexibility", () => { const call = Tool.make({ description: "Observe raw input", input: { type: "object" }, - run: (input) => + execute: (input) => Effect.sync(() => { observed.push(input) return "ok" @@ -483,7 +483,7 @@ describe("CodeMode schema flexibility", () => { const find = Tool.make({ description: "Find things", input: Schema.Struct({ query: Schema.optionalKey(Schema.String), limit: Schema.optionalKey(Schema.Number) }), - run: (input) => + execute: (input) => Effect.sync(() => { observed.push(input) return "ok" @@ -517,7 +517,7 @@ describe("CodeMode schema flexibility", () => { }, }, }, - run: () => Effect.succeed({ login: "kit", id: 7 }), + execute: () => Effect.succeed({ login: "kit", id: 7 }), }) const runtime = CodeMode.make({ tools: { users: { lookup } } }) @@ -534,18 +534,18 @@ describe("CodeMode schema flexibility", () => { if (result.ok) expect(result.value).toStrictEqual({ login: "kit", id: 7 }) }) - test("Effect Schema output without an input transform still renders unknown when omitted", async () => { + test("Effect Schema output without an input transform renders void when omitted", async () => { const ping = Tool.make({ description: "Ping", input: Schema.Struct({ host: Schema.String }), - run: () => Effect.succeed("pong"), + execute: () => Effect.succeed("pong"), }) const runtime = CodeMode.make({ tools: { net: { ping } } }) - expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise") + expect(runtime.catalog()[0]?.signature).toBe("tools.net.ping(input: {\n host: string,\n}): Promise") const result = await Effect.runPromise(runtime.execute(`return await tools.net.ping({ host: "example.test" })`)) expect(result.ok).toBe(true) - if (result.ok) expect(result.value).toBe("pong") + if (result.ok) expect(result.value).toBeNull() }) }) @@ -554,7 +554,7 @@ describe("CodeMode public contract", () => { description: "Look up an order by ID", input: Schema.Struct({ id: Schema.String }), output: Schema.Struct({ id: Schema.String, status: Schema.String }), - run: ({ id }) => Effect.succeed({ id, status: "open" }), + execute: ({ id }) => Effect.succeed({ id, status: "open" }), }) const tools = { orders: { lookup } } const source = `return await tools.orders.lookup({ id: "order_42" })` @@ -577,7 +577,7 @@ describe("CodeMode public contract", () => { description: "echo", input: Schema.Struct({}), output: Schema.Number, - run: () => Effect.succeed(1), + execute: () => Effect.succeed(1), }) const effect = CodeMode.execute({ tools: { host: { echo } }, @@ -634,7 +634,7 @@ describe("CodeMode public contract", () => { description: "Resolve a library ID", input: Schema.Struct({ libraryName: Schema.String }), output: Schema.String, - run: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`), + execute: ({ libraryName }) => Effect.succeed(`/resolved/${libraryName}`), }) const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) @@ -760,18 +760,18 @@ describe("CodeMode public contract", () => { expect(instructions).not.toContain("search(") }) - test("uses one ranked search returning complete definitions for large catalogs", async () => { + test("uses one ranked search returning complete tools for large catalogs", async () => { const upload = Tool.make({ description: "Upload one readable local file to the current Discord thread", input: Schema.Struct({ path: Schema.String }), output: Schema.Struct({ sent: Schema.Boolean }), - run: () => Effect.succeed({ sent: true }), + execute: () => Effect.succeed({ sent: true }), }) const generate = Tool.make({ description: "Generate an image and upload it to the current Discord thread", input: Schema.Struct({ prompt: Schema.String }), output: Schema.Struct({ sent: Schema.Boolean }), - run: () => Effect.succeed({ sent: true }), + execute: () => Effect.succeed({ sent: true }), }) const runtime = CodeMode.make({ tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, @@ -865,7 +865,7 @@ describe("CodeMode public contract", () => { description: `Numbered tool ${index}`, input: Schema.Struct({ id: Schema.String }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const runtime = CodeMode.make({ tools: { @@ -911,7 +911,7 @@ describe("CodeMode public contract", () => { description, input: Schema.Struct({ id: Schema.String }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const runtime = CodeMode.make({ tools: { @@ -954,13 +954,13 @@ describe("CodeMode public contract", () => { properties: { attachment: { type: "string", description: "Local path of the payload to send" } }, required: ["attachment"], }, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const other = Tool.make({ description: "Rename the workspace", input: Schema.Struct({ name: Schema.String }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const runtime = CodeMode.make({ tools: { files: { upload, other } } }) @@ -990,7 +990,7 @@ describe("CodeMode public contract", () => { description, input: Schema.Struct({ id: Schema.String }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const runtime = CodeMode.make({ tools: { @@ -1029,7 +1029,7 @@ describe("CodeMode public contract", () => { description, input: Schema.Struct({}), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) // Deliberately declared out of alphabetical order. const runtime = CodeMode.make({ @@ -1071,7 +1071,7 @@ describe("CodeMode public contract", () => { description: "Cheap", input: Schema.Struct({ q: Schema.String }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const expensive = Tool.make({ description: @@ -1081,7 +1081,7 @@ describe("CodeMode public contract", () => { anotherEvenLongerParameterName: Schema.Number, }), output: Schema.String, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) // Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2 // alpha.expensive does not fit, which marks only alpha done - it must NOT prevent @@ -1112,7 +1112,7 @@ describe("CodeMode public contract", () => { }, required: ["id"], } as const, - run: () => Effect.succeed("ok"), + execute: () => Effect.succeed("ok"), }) const runtime = CodeMode.make({ tools: { records: { lookup: documented } }, @@ -1130,7 +1130,7 @@ describe("CodeMode public contract", () => { description: "Double a number", input: Schema.Struct({ value: Schema.NumberFromString }), output: Schema.NumberFromString, - run: ({ value }) => + execute: ({ value }) => Effect.sync(() => { observed.push(value) return String(value * 2) @@ -1226,7 +1226,7 @@ describe("CodeMode public contract", () => { description: "Count invocations", input: Schema.Struct({}), output: Schema.Number, - run: () => Effect.succeed(1), + execute: () => Effect.succeed(1), }) const result = await Effect.runPromise( CodeMode.execute({ diff --git a/packages/codemode/test/enumeration.test.ts b/packages/codemode/test/enumeration.test.ts index 116fbab0d9e..981297c7f8f 100644 --- a/packages/codemode/test/enumeration.test.ts +++ b/packages/codemode/test/enumeration.test.ts @@ -13,7 +13,7 @@ const echo = (description: string) => description, input: Schema.Struct({ value: Schema.String }), output: Schema.String, - run: ({ value }) => Effect.succeed(value), + execute: ({ value }) => Effect.succeed(value), }) const tools = { diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index f4da5c31e0d..da705977bfa 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -143,12 +143,7 @@ describe("OpenAPI.fromSpec", () => { const remove = toolAt(api.tools, "users.remove") expect(api.skipped).toEqual([]) - if ( - !Tool.isDefinition(get) || - !Tool.isDefinition(create) || - !Tool.isDefinition(search) || - !Tool.isDefinition(remove) - ) { + if (!Tool.isTool(get) || !Tool.isTool(create) || !Tool.isTool(search) || !Tool.isTool(remove)) { throw new Error("happy-path fixture did not generate every operation") } expect(inputTypeScript(get)).toBe( @@ -241,23 +236,23 @@ describe("OpenAPI.fromSpec", () => { expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined() const sessionGet = toolAt(result.tools, "v2.session.get") - expect(Tool.isDefinition(sessionGet)).toBe(true) - if (!Tool.isDefinition(sessionGet)) throw new Error("v2.session.get was not generated") + expect(Tool.isTool(sessionGet)).toBe(true) + if (!Tool.isTool(sessionGet)) throw new Error("v2.session.get was not generated") expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }") expect(outputTypeScript(sessionGet)).toContain("id: string") expect(outputTypeScript(sessionGet)).toContain("additions: number") const switchAgent = toolAt(result.tools, "v2.session.switchAgent") - expect(Tool.isDefinition(switchAgent)).toBe(true) - if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated") + expect(Tool.isTool(switchAgent)).toBe(true) + if (!Tool.isTool(switchAgent)) throw new Error("v2.session.switchAgent was not generated") expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }") const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put") - expect(Tool.isDefinition(instructionPut)).toBe(true) - if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated") + expect(Tool.isTool(instructionPut)).toBe(true) + if (!Tool.isTool(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated") expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }") expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined() - expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false) + expect(Tool.isTool(toolAt(result.tools, "v2.pty.connect"))).toBe(false) expect(toolAt(result.tools, "v2.session.log")).toBeUndefined() expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined() expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined() @@ -278,9 +273,9 @@ describe("OpenAPI.fromSpec", () => { }, }) - expect(Tool.isDefinition(toolAt(result.tools, "group.item"))).toBe(true) - expect(Tool.isDefinition(toolAt(result.tools, "group_item_2"))).toBe(true) - expect(Tool.isDefinition(toolAt(result.tools, "group.operation.other"))).toBe(true) + expect(Tool.isTool(toolAt(result.tools, "group.item"))).toBe(true) + expect(Tool.isTool(toolAt(result.tools, "group_item_2"))).toBe(true) + expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true) }) test("synthesizes flat operation IDs from methods and paths", () => { @@ -305,7 +300,7 @@ describe("OpenAPI.fromSpec", () => { "deleteUsersById", "getOrganizationsByOrganizationidUsersById", ]) { - expect(Tool.isDefinition(toolAt(tools, path))).toBe(true) + expect(Tool.isTool(toolAt(tools, path))).toBe(true) } }) @@ -330,7 +325,7 @@ describe("OpenAPI.fromSpec", () => { "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ limit: number }") }) @@ -358,8 +353,8 @@ describe("OpenAPI.fromSpec", () => { }) const search = toolAt(result.tools, "search") - expect(Tool.isDefinition(search)).toBe(true) - if (!Tool.isDefinition(search)) throw new Error("search was not generated") + expect(Tool.isTool(search)).toBe(true) + if (!Tool.isTool(search)) throw new Error("search was not generated") expect(inputTypeScript(search)).toBe("{ value?: string | null }") const schema: unknown = search.input const input = isRecord(schema) ? schema : {} @@ -397,14 +392,14 @@ describe("OpenAPI.fromSpec", () => { "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.output)) throw new Error("test output was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.output)) throw new Error("test output was not generated") expect(tool.output.$defs).toMatchObject({ Local: { type: "string" }, Global: { type: "number" } }) }) test("projects read-only and write-only properties by schema direction", () => { for (const version of ["3.0.3", "3.1.0"]) { const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec(version) }).tools, "users.create") - if (!Tool.isDefinition(tool) || !isRecord(tool.input) || !isRecord(tool.output)) { + if (!Tool.isTool(tool) || !isRecord(tool.input) || !isRecord(tool.output)) { throw new Error(`users.create was not generated for OpenAPI ${version}`) } @@ -467,7 +462,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ name: string }") }) @@ -518,7 +513,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const properties = isRecord(tool.input.properties) ? tool.input.properties : {} const record = isRecord(properties.record) ? properties.record : {} const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} @@ -567,7 +562,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ name: string }") }) @@ -607,7 +602,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} const node = isRecord(definitions.Node) ? definitions.Node : {} @@ -648,7 +643,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const definitions = isRecord(tool.input.$defs) ? tool.input.$defs : {} const leaf = isRecord(definitions[`C${depth - 1}`]) ? definitions[`C${depth - 1}`] : {} @@ -686,7 +681,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ name: string }") } @@ -725,7 +720,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const properties = isRecord(tool.input.properties) ? tool.input.properties : {} const record: Record = isRecord(properties.record) ? properties.record : {} @@ -763,7 +758,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const properties = isRecord(tool.input.properties) ? tool.input.properties : {} const choice: Record = isRecord(properties.choice) ? properties.choice : {} const pick: Record = isRecord(properties.pick) ? properties.pick : {} @@ -807,7 +802,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const properties = isRecord(tool.input.properties) ? tool.input.properties : {} const record = isRecord(properties.record) ? properties.record : {} @@ -836,7 +831,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ filter: { state: string } }") }) @@ -866,7 +861,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") expect(inputTypeScript(tool)).toBe("{ filter: { value: string } }") }) @@ -901,7 +896,7 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool) || !isRecord(tool.input)) throw new Error("test was not generated") + if (!Tool.isTool(tool) || !isRecord(tool.input)) throw new Error("test was not generated") const properties = isRecord(tool.input.properties) ? tool.input.properties : {} const body = isRecord(properties.body) ? properties.body : {} const allOf = Array.isArray(body.allOf) ? body.allOf : [] @@ -923,11 +918,11 @@ describe("OpenAPI.fromSpec", () => { }), ) const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: directionalSpec("3.1.0") }).tools, "users.create") - if (!Tool.isDefinition(tool)) throw new Error("users.create was not generated") + if (!Tool.isTool(tool)) throw new Error("users.create was not generated") const result = await Effect.runPromise( tool - .run({ + .execute({ id: "ignored-top-level", generated: "ignored-generated", name: "Ada", @@ -1022,10 +1017,12 @@ describe("OpenAPI.fromSpec", () => { test("serializes deep-object query parameters from the opencode fixture", async () => { const client = recordingClient(() => json({ directory: "/tmp" })) const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get") - if (!Tool.isDefinition(location)) throw new Error("v2.location.get was not generated") + if (!Tool.isTool(location)) throw new Error("v2.location.get was not generated") await Effect.runPromise( - location.run({ location: { directory: "/tmp", workspace: "workspace-1" } }).pipe(Effect.provide(client.layer)), + location + .execute({ location: { directory: "/tmp", workspace: "workspace-1" } }) + .pipe(Effect.provide(client.layer)), ) const url = new URL(client.requests[0]!.url) @@ -1058,11 +1055,11 @@ describe("OpenAPI.fromSpec", () => { }, }) const tool = toolAt(result.tools, "items") - if (!Tool.isDefinition(tool)) throw new Error("items was not generated") + if (!Tool.isTool(tool)) throw new Error("items was not generated") await Effect.runPromise( tool - .run({ + .execute({ keys: ["a!", "b*"], tags: ["x", "y"], filter: { state: "open", page: 2 }, @@ -1081,9 +1078,9 @@ describe("OpenAPI.fromSpec", () => { expect(url.searchParams.get("nullable")).toBe("null") expect(url.searchParams.get("constructor")).toBe("safe") expect(client.requests[0]!.headers.meta).toBe("a=b,c=d") - await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow( - "unsupported nested value", - ) + await expect( + Effect.runPromise(tool.execute({ keys: [undefined] }).pipe(Effect.provide(client.layer))), + ).rejects.toThrow("unsupported nested value") }) test("preserves ordered exploded and deep-object query parameters", async () => { @@ -1101,11 +1098,11 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") await Effect.runPromise( tool - .run({ + .execute({ tags: ["first value", "second&value"], filter: { state: "open now", page: 2 }, location: { directory: "/tmp/a b", workspace: "work&1" }, @@ -1116,14 +1113,14 @@ describe("OpenAPI.fromSpec", () => { expect(client.requests[0]?.url).toBe( `${baseUrl}/test?tags=first+value&tags=second%26value&state=open+now&page=2&location%5Bdirectory%5D=%2Ftmp%2Fa+b&location%5Bworkspace%5D=work%261`, ) - await expect(Effect.runPromise(tool.run({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow( + await expect(Effect.runPromise(tool.execute({ tags: [{}] }).pipe(Effect.provide(client.layer)))).rejects.toThrow( "Parameter 'tags' contains an unsupported nested value.", ) await expect( - Effect.runPromise(tool.run({ filter: { state: {} } }).pipe(Effect.provide(client.layer))), + Effect.runPromise(tool.execute({ filter: { state: {} } }).pipe(Effect.provide(client.layer))), ).rejects.toThrow("Query parameter 'filter' contains an unsupported nested value.") await expect( - Effect.runPromise(tool.run({ location: { directory: [] } }).pipe(Effect.provide(client.layer))), + Effect.runPromise(tool.execute({ location: { directory: [] } }).pipe(Effect.provide(client.layer))), ).rejects.toThrow("Deep-object parameter 'location' contains an unsupported nested value.") expect(client.requests).toHaveLength(1) }) @@ -1203,9 +1200,9 @@ describe("OpenAPI.fromSpec", () => { }).tools, "getTest", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") - await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer))) + await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer))) expect(inputTypeScript(tool)).toBe("{}") expect(client.requests[0]!.headers.authorization).toBe("Bearer secret") @@ -1240,9 +1237,9 @@ describe("OpenAPI.fromSpec", () => { authenticated([{ key: [] }], { key: { type: "apiKey", in: "query", name: "__proto__" } }).tools, "test", ) - if (!Tool.isDefinition(prototype)) throw new Error("prototype auth tool was not generated") + if (!Tool.isTool(prototype)) throw new Error("prototype auth tool was not generated") - await Effect.runPromise(prototype.run({}).pipe(Effect.provide(client.layer))) + await Effect.runPromise(prototype.execute({}).pipe(Effect.provide(client.layer))) expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret") const duplicate = toolAt( @@ -1252,8 +1249,8 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated") - await expect(Effect.runPromise(duplicate.run({}).pipe(Effect.provide(client.layer)))).rejects.toThrow( + if (!Tool.isTool(duplicate)) throw new Error("duplicate auth tool was not generated") + await expect(Effect.runPromise(duplicate.execute({}).pipe(Effect.provide(client.layer)))).rejects.toThrow( "multiple credentials", ) @@ -1278,8 +1275,8 @@ describe("OpenAPI.fromSpec", () => { }, }) const alternativeTool = toolAt(alternative.tools, "test") - if (!Tool.isDefinition(alternativeTool)) throw new Error("supported auth alternative was not generated") - await Effect.runPromise(alternativeTool.run({}).pipe(Effect.provide(client.layer))) + if (!Tool.isTool(alternativeTool)) throw new Error("supported auth alternative was not generated") + await Effect.runPromise(alternativeTool.execute({}).pipe(Effect.provide(client.layer))) expect(client.requests.at(-1)?.headers.authorization).toBe("Bearer secret") }) @@ -1290,9 +1287,9 @@ describe("OpenAPI.fromSpec", () => { servers: [{ url: "https://document.example" }], } satisfies Document const tool = toolAt(OpenAPI.fromSpec({ spec }).tools, "test") - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") - await Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer))) + await Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer))) expect(client.requests[0]?.url).toBe("https://operation.example/v1/test") const invalid = OpenAPI.fromSpec({ spec, baseUrl: "https://example.com/api?tenant=one" }) @@ -1363,10 +1360,10 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") await expect( - Effect.runPromise(tool.run({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))), + Effect.runPromise(tool.execute({ filter: { value: undefined } }).pipe(Effect.provide(client.layer))), ).rejects.toThrow("unsupported nested value") expect(resolutions).toEqual([]) expect(client.requests).toEqual([]) @@ -1389,33 +1386,33 @@ describe("OpenAPI.fromSpec", () => { }).tools, "test", ) - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") - await Effect.runPromise(tool.run({ body: { name: "updated" } }).pipe(Effect.provide(client.layer))) + await Effect.runPromise(tool.execute({ body: { name: "updated" } }).pipe(Effect.provide(client.layer))) expect(client.requests[0]!.headers["content-type"]).toBe("application/merge-patch+json") const cyclic: Record = {} cyclic.self = cyclic - await expect(Effect.runPromise(tool.run({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow( + await expect(Effect.runPromise(tool.execute({ body: cyclic }).pipe(Effect.provide(client.layer)))).rejects.toThrow( "Invalid JSON body", ) }) test("rejects oversized and malformed JSON responses", async () => { const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec: singleOperation({}) }).tools, "test") - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") const oversized = recordingClient( () => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }), ) const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } })) const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1))) - await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow( + await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow( "response exceeds 50 MiB", ) - await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow( + await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(malformed.layer)))).rejects.toThrow( "returned malformed JSON", ) - await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow( + await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(chunked.layer)))).rejects.toThrow( "response exceeds 50 MiB", ) }) @@ -1428,11 +1425,11 @@ describe("OpenAPI.fromSpec", () => { }, }) const tool = toolAt(OpenAPI.fromSpec({ baseUrl, spec }).tools, "test") - if (!Tool.isDefinition(tool)) throw new Error("test was not generated") + if (!Tool.isTool(tool)) throw new Error("test was not generated") const client = recordingClient(() => new Response("123", { headers: { "content-type": "text/plain" } })) expect(outputTypeScript(tool)).toBe("string | null") - await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123") + await expect(Effect.runPromise(tool.execute({}).pipe(Effect.provide(client.layer)))).resolves.toBe("123") }) test("fails missing required parameters before auth and network", async () => { @@ -1497,13 +1494,13 @@ describe("OpenAPI.fromSpec", () => { const update = toolAt(tools, "things.update") const echo = toolAt(tools, "echo") - expect(Tool.isDefinition(update)).toBe(true) - if (!Tool.isDefinition(update)) throw new Error("things.update was not generated") + expect(Tool.isTool(update)).toBe(true) + if (!Tool.isTool(update)) throw new Error("things.update was not generated") expect(inputTypeScript(update)).toBe( "{ path_id: string; query_id: string; path_id_2?: string; header_id: string; body_id: string }", ) - expect(Tool.isDefinition(echo)).toBe(true) - if (!Tool.isDefinition(echo)) throw new Error("echo was not generated") + expect(Tool.isTool(echo)).toBe(true) + if (!Tool.isTool(echo)) throw new Error("echo was not generated") expect(inputTypeScript(echo)).toBe("{ body: string }") const runtime = CodeMode.make({ tools }) @@ -1584,13 +1581,13 @@ describe("OpenAPI.fromSpec", () => { for (const name of ["optional", "dictionary", "composed", "nullable"]) { const tool = toolAt(tools, `body.${name}`) - expect(Tool.isDefinition(tool)).toBe(true) - if (!Tool.isDefinition(tool)) throw new Error(`body.${name} was not generated`) + expect(Tool.isTool(tool)).toBe(true) + if (!Tool.isTool(tool)) throw new Error(`body.${name} was not generated`) const input = isRecord(tool.input) ? tool.input : {} expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual(["body"]) } const optional = toolAt(tools, "body.optional") - if (!Tool.isDefinition(optional)) throw new Error("body.optional was not generated") + if (!Tool.isTool(optional)) throw new Error("body.optional was not generated") expect(inputTypeScript(optional)).toBe("{ body?: { name: string } }") }) }) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index f5b52eeefdd..63d8540e900 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -33,7 +33,7 @@ const echoTool = (trace: Trace) => description: "Echo an id immediately", input: Schema.Struct({ id: Schema.Number }), output: Schema.Number, - run: ({ id }) => + execute: ({ id }) => Effect.sync(() => { trace.starts.push(id) trace.completed += 1 @@ -46,7 +46,7 @@ const gatedTool = (trace: Trace, gate: (id: number) => Deferred.Deferred) description: "Echo an id once its gate opens", input: Schema.Struct({ id: Schema.Number }), output: Schema.Number, - run: ({ id }) => + execute: ({ id }) => Effect.gen(function* () { trace.starts.push(id) trace.active += 1 @@ -70,7 +70,7 @@ const openTool = (gate: (id: number) => Deferred.Deferred) => description: "Open the gate for an id", input: Schema.Struct({ id: Schema.Number }), output: Schema.Boolean, - run: ({ id }) => Deferred.succeed(gate(id), undefined), + execute: ({ id }) => Deferred.succeed(gate(id), undefined), }) const pendingTool = (trace: Trace) => @@ -78,7 +78,7 @@ const pendingTool = (trace: Trace) => description: "Never settle", input: Schema.Struct({ id: Schema.Number }), output: Schema.Number, - run: ({ id }) => + execute: ({ id }) => Effect.gen(function* () { trace.starts.push(id) trace.active += 1 @@ -98,14 +98,14 @@ const failingTool = Tool.make({ description: "Always refuse", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.fail(toolError("Lookup refused")), + execute: () => Effect.fail(toolError("Lookup refused")), }) const interruptedTool = Tool.make({ description: "Interrupt this call", input: Schema.Struct({}), output: Schema.String, - run: () => Effect.interrupt, + execute: () => Effect.interrupt, }) const completedTool = (trace: Trace) => @@ -113,7 +113,7 @@ const completedTool = (trace: Trace) => description: "Return the number of completed calls", input: Schema.Struct({}), output: Schema.Number, - run: () => Effect.succeed(trace.completed), + execute: () => Effect.succeed(trace.completed), }) /** Never settles, and holds interruption cleanup for `cleanupMs` so completion cleanup can outlast a timeout. */ @@ -122,7 +122,7 @@ const stubbornTool = (trace: Trace) => description: "Never settle; clean up slowly when interrupted", input: Schema.Struct({ cleanupMs: Schema.Number }), output: Schema.Number, - run: ({ cleanupMs }) => + execute: ({ cleanupMs }) => Effect.never.pipe( Effect.onInterrupt(() => Effect.andThen( diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index dea0e890ac5..38121e5c6ef 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -18,7 +18,8 @@ const listIssues = Tool.make({ }, required: ["owner"], }, - run: () => Effect.succeed("[]"), + output: {}, + execute: () => Effect.succeed("[]"), }) // An Effect Schema tool whose field annotations must flow through the emitted JSON Schema. @@ -31,7 +32,7 @@ const lookupOrder = Tool.make({ output: Schema.Struct({ status: Schema.String.annotate({ description: "Current order status" }), }), - run: () => Effect.succeed({ status: "open" }), + execute: () => Effect.succeed({ status: "open" }), }) describe("pretty signature rendering", () => { @@ -261,7 +262,7 @@ describe("non-identifier property names render as quoted keys", () => { properties: { "content-type": { type: "string" } }, required: ["content-type"], } as const, - run: () => Effect.succeed({ "content-type": "text/plain" }), + execute: () => Effect.succeed({ "content-type": "text/plain" }), }) expect(inputTypeScript(tool)).toContain('"foo-bar"?: string') expect(outputTypeScript(tool)).toBe('{ "content-type": string }') @@ -272,7 +273,7 @@ describe("non-identifier property names render as quoted keys", () => { const tool = Tool.make({ description: "Schema tool with awkward field names", input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }), - run: () => Effect.succeed(null), + execute: () => Effect.succeed(null), }) expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }') expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n")) @@ -306,7 +307,7 @@ describe("union schemas render every alternative", () => { }, } as const, output: { anyOf: [{ type: "number" }, { type: "boolean" }] } as const, - run: () => Effect.succeed(1), + execute: () => Effect.succeed(1), }) expect(inputTypeScript(tool)).toBe("{ value?: string | number }") expect(outputTypeScript(tool)).toBe("number | boolean") @@ -417,7 +418,8 @@ describe("non-identifier tool paths", () => { }, required: ["query", "libraryName"], } as const, - run: () => Effect.succeed("/reactjs/react.dev"), + output: {}, + execute: () => Effect.succeed("/reactjs/react.dev"), }) const runtime = CodeMode.make({ tools: { context7: { "resolve-library-id": resolveLibrary } } }) diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index 19989b75a5f..e3a840fd0ff 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -329,7 +329,7 @@ describe("RegExp", () => { description: "Decorate a string", input: Schema.String, output: Schema.String, - run: (input) => Effect.succeed(`[${input}]`), + execute: (input) => Effect.succeed(`[${input}]`), }) const result = await Effect.runPromise( CodeMode.execute({ @@ -1028,7 +1028,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => { const capture = Tool.make({ description: "Capture the exact input the host receives", input: { type: "object" }, - run: (input) => + execute: (input) => Effect.sync(() => { observed.push(input) return "ok" diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index c92739c5df3..91d23cdee71 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -7,7 +7,7 @@ const echo = (description: string, result: string) => description, input: Schema.Struct({}), output: Schema.String, - run: () => Effect.succeed(result), + execute: () => Effect.succeed(result), }) const value = async (runtime: CodeMode.Runtime, code: string) => { @@ -88,7 +88,7 @@ describe("callable namespaces", () => { expect(diagnostic.message).toContain("Unknown tool 'issues.missing'") }) - test("a namespace without its own definition stays non-callable", async () => { + test("a namespace without its own tool stays non-callable", async () => { const nested = CodeMode.make({ tools: { "issues.list": echo("List issues", "list") } }) const diagnostic = await failure(nested, `return await tools.issues({})`) expect(diagnostic.kind).toBe("UnknownTool") @@ -114,9 +114,9 @@ describe("blocked member names on tool paths", () => { expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["constructor"]) }) - test("a literal __proto__ key cannot poison a namespace into a fake definition", async () => { + test("a literal __proto__ key cannot poison a namespace into a fake tool", async () => { const poisoned = CodeMode.make({ - tools: { ns: { "__proto__": echo("Hidden", "hidden"), real: echo("Real tool", "real") } }, + tools: { ns: { __proto__: echo("Hidden", "hidden"), real: echo("Real tool", "real") } }, }) expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"]) expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real") @@ -138,7 +138,7 @@ describe("empty segments", () => { }) describe("canonical path collisions", () => { - test("the last definition supplied for a canonical path wins", async () => { + test("the last tool supplied for a canonical path wins", async () => { const runtime = CodeMode.make({ tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } }, }) diff --git a/packages/core/src/codemode.ts b/packages/core/src/codemode.ts index cd7e9ee5573..7d2dd2c5708 100644 --- a/packages/core/src/codemode.ts +++ b/packages/core/src/codemode.ts @@ -4,19 +4,17 @@ import { Context, Effect, Layer, Scope } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { PermissionV2 } from "./permission" import { ExecuteTool } from "./tool/execute" -import { permission, registrationEntries, type AnyTool } from "./tool/tool" -import { Tools } from "./tool/tools" +import type { Any, Registration } from "./tool/tool" import { Wildcard } from "./util/wildcard" export interface Materialization { - readonly tool?: AnyTool + readonly tool?: Any readonly instructions?: string } export interface Interface { readonly register: ( - tools: Readonly>, - options?: Tools.RegisterOptions, + registrations: ReadonlyArray, ) => Effect.Effect readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect } @@ -26,29 +24,28 @@ export class Service extends Context.Service()("@opencode/v2 const layer = Layer.effect( Service, Effect.gen(function* () { - const local = new Map< - string, - Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }> - >() + const local = new Map>() return Service.of({ - register: Effect.fn("CodeMode.register")(function* (tools, options) { - const entries = registrationEntries(tools, options?.namespace) - if (entries.length === 0) return + register: Effect.fn("CodeMode.register")(function* (registrations) { + if (registrations.length === 0) return yield* Effect.uninterruptible( Effect.gen(function* () { const token = {} - for (const entry of entries) - local.set(entry.key, [ - ...(local.get(entry.key) ?? []), - { token, registration: { tool: entry.tool, name: entry.name, namespace: entry.namespace } }, + for (const registration of registrations) + local.set(registration.key, [ + ...(local.get(registration.key) ?? []), + { + token, + registration, + }, ]) yield* Effect.addFinalizer(() => Effect.sync(() => { - for (const entry of entries) { - const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? [] - if (registrations.length > 0) local.set(entry.key, registrations) - else local.delete(entry.key) + for (const registration of registrations) { + const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? [] + if (remaining.length > 0) local.set(registration.key, remaining) + else local.delete(registration.key) } }), ) @@ -61,7 +58,7 @@ const layer = Layer.effect( for (const [name, entries] of local) { const registration = entries.at(-1)?.registration if (!registration) continue - const rule = rules.findLast((rule) => Wildcard.match(permission(registration.tool, name), rule.action)) + const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action)) if (rule?.resource === "*" && rule.effect === "deny") continue registrations.set(name, registration) } diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index dd6214b095a..a7eaed9b732 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -56,5 +56,6 @@ export const migrations = ( import("./migration/20260710025429_instruction_sync"), import("./migration/20260716020354_kv"), import("./migration/20260722011141_delete_tool_progress_events"), + import("./migration/20260722170000_canonical_tool_results"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260722170000_canonical_tool_results.ts b/packages/core/src/database/migration/20260722170000_canonical_tool_results.ts new file mode 100644 index 00000000000..2cb656cd026 --- /dev/null +++ b/packages/core/src/database/migration/20260722170000_canonical_tool_results.ts @@ -0,0 +1,123 @@ +import { sql } from "drizzle-orm" +import { Effect, Schema } from "effect" +import type { DatabaseMigration } from "../migration" + +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown)) +const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json)) + +const object = (value: unknown): Record => (isObject(value) ? value : {}) + +const stringify = (value: unknown) => { + try { + return JSON.stringify(value, null, 2) ?? String(value) + } catch { + return String(value) + } +} + +const contentOf = (state: Record) => (Array.isArray(state.content) ? state.content : []) +const resultOf = (state: Record) => + isObject(state.result) && "value" in state.result ? state.result.value : state.result +const metadataOf = (state: Record) => { + if (isJsonObject(state.structured) && Object.keys(state.structured).length > 0) + return { metadata: state.structured } + return isJsonObject(state.metadata) ? { metadata: state.metadata } : {} +} +const completedContent = (state: Record) => { + const preserved = contentOf(state) + if (preserved.length > 0) return preserved + return [{ type: "text", text: stringify(Object.keys(object(state.structured)).length ? state.structured : resultOf(state)) }] +} + +/** + * One-time rewrite of projected tool rows into the canonical result shape: + * terminal states store model content plus optional metadata; the generic + * `structured` and `result` fields disappear. Provider-hosted result payloads + * move into provider-owned result state so hosted continuation survives. + * Pre-release durable event versions are intentionally left untouched. + */ +export default { + id: "20260722170000_canonical_tool_results", + up(tx) { + return Effect.gen(function* () { + // Keyset-paginated batches keep memory bounded: production databases hold + // gigabytes of assistant rows, and materializing them all at once was + // measured at a ~5GB RSS spike. + let cursor = "" + while (true) { + const messages = yield* tx.all<{ id: string; data: string }>( + sql`SELECT id, data FROM session_message WHERE type = 'assistant' AND id > ${cursor} ORDER BY id LIMIT 1000`, + ) + if (messages.length === 0) break + cursor = messages[messages.length - 1].id + yield* rewrite(tx, messages) + } + }) + }, +} satisfies DatabaseMigration.Migration + +function rewrite(tx: Parameters[0], messages: { id: string; data: string }[]) { + return Effect.gen(function* () { + for (const row of messages) { + // A row that never decoded is skipped rather than failing the whole + // migration on every startup; it was equally unreadable before. + const decoded = decodeJson(row.data) + if (decoded._tag === "None") { + yield* Effect.logWarning("skipping undecodable session_message row").pipe(Effect.annotateLogs({ id: row.id })) + continue + } + const data = object(decoded.value) + if (!Array.isArray(data.content)) continue + let changed = false + const content = data.content.map((part) => { + const tool = object(part) + if (tool.type !== "tool" || !isObject(tool.state)) return part + const state = tool.state + if (state.status !== "completed" && state.status !== "error" && state.status !== "running") return part + if (!("structured" in state) && !("result" in state)) return part + changed = true + if (state.status === "running") + return { + ...tool, + state: { + status: "running", + input: object(state.input), + metadata: object(state.structured), + }, + } + // Hosted payloads are irreducible provider replay state; keep them under + // the provider-owned result state instead of a generic result field. + const hosted = + tool.executed === true && isObject(state.result) && "value" in state.result + ? { providerResultState: { ...object(tool.providerResultState), result: state.result.value } } + : {} + const preserved = contentOf(state) + if (state.status === "completed") + return { + ...tool, + ...hosted, + state: { + status: "completed", + input: object(state.input), + content: completedContent(state), + ...metadataOf(state), + }, + } + return { + ...tool, + ...hosted, + state: { + status: "error", + input: object(state.input), + error: state.error, + ...(preserved.length > 0 ? { content: preserved } : {}), + ...metadataOf(state), + }, + } + }) + if (!changed) continue + yield* tx.run(sql`UPDATE session_message SET data = ${JSON.stringify({ ...data, content })} WHERE id = ${row.id}`) + } + }) +} diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index a97b6bf0bb4..7761aa18235 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -127,8 +127,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int ), }, model: { - get: (providerID, modelID) => - catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), + get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), list: () => response(catalog.model.available()), default: () => response(catalog.model.default()), }, @@ -358,7 +357,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int Effect.gen(function* () { const registrations: Array<{ readonly name: string - readonly tool: Tool.AnyTool + readonly tool: Tool.Any readonly options?: Tool.RegisterOptions }> = [] yield* Effect.sync(() => @@ -395,25 +394,42 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }) } return toolHooks.hook.after((event) => { - const output = { + // JS plugin boundary: marshal the canonical outcome out, copy mutations back. + const output: Record = { tool: event.tool, sessionID: event.sessionID, agent: event.agent, messageID: event.messageID, callID: event.callID, input: event.input, - result: event.result, - output: event.output, + status: event.status, + content: event.content, + metadata: event.metadata, outputPaths: event.outputPaths, + ...(event.status === "error" ? { error: event.error } : {}), } return Reflect.apply(callback, undefined, [output]).pipe( - Effect.tap(() => - Effect.sync(() => { - event.result = output.result - event.output = output.output - event.outputPaths = output.outputPaths - }), - ), + Effect.tap(() => { + const decoded = Schema.decodeUnknownOption(Tool.ExecuteAfterOutcome)(output) + if (decoded._tag === "None") + return Effect.logWarning("ignoring invalid execute.after tool outcome", { tool: event.tool }) + if (decoded.value.status !== event.status) + return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool }) + return Effect.sync(() => { + if (event.status === "completed" && decoded.value.status === "completed") { + if (output.content !== event.content) event.content = decoded.value.content + if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata + if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths + return + } + if (event.status === "error" && decoded.value.status === "error") { + if (output.error !== event.error) event.error = decoded.value.error + if (output.content !== event.content) event.content = decoded.value.content + if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata + if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths + } + }) + }), ) }) }, diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index e54365e79f6..950f6a2afcb 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -2,7 +2,7 @@ export * as PluginPromise from "./promise" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import type { Context, Plugin } from "@opencode-ai/plugin/v2/plugin" -import type { AnyTool } from "@opencode-ai/plugin/v2/tool" +import type { Any, RegisterOptions } from "@opencode-ai/plugin/v2/tool" import { Agent } from "@opencode-ai/schema/agent" import { Integration } from "@opencode-ai/schema/integration" import { Location } from "@opencode-ai/schema/location" @@ -189,7 +189,8 @@ export function fromPromise(plugin: Plugin) { register( host.tool.transform((draft) => callback({ - add: (tool: AnyTool) => draft.add(tool.name, fromPromiseTool(tool), tool.options), + add: (name: string, tool: Any, options?: RegisterOptions) => + draft.add(name, fromPromiseTool(tool), options), }), ), ), @@ -302,19 +303,8 @@ function wireEvent(value: unknown): unknown { return wire(value) } -function fromPromiseTool(tool: AnyTool) { - if ("jsonSchema" in tool) - return Tool.make({ - ...tool, - execute: (input, context) => - Effect.promise(() => - tool.execute(input, { - ...context, - progress: (update) => Effect.runPromise(context.progress(update)), - }), - ), - }) - return Tool.make({ +function fromPromiseTool(tool: Any): Tool.Any { + return { ...tool, execute: (input, context) => Effect.promise(() => @@ -323,5 +313,5 @@ function fromPromiseTool(tool: AnyTool) { progress: (update) => Effect.runPromise(context.progress(update)), }), ), - }) + } } diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index 96804fc57f7..0158760c3bf 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -36,8 +36,8 @@ export const layer = Layer.effect( const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id) ? selection.session.id.slice(4) : selection.session.id - const executableTools = yield* registry.materialize(selection.agent.info.permissions) - const toolDefinitions = executableTools.definitions + const toolSet = yield* registry.snapshot(selection.agent.info.permissions) + const toolDefinitions = toolSet.definitions const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool])) const contextEvent = yield* hooks.trigger("session", "context", { sessionID: selection.session.id, @@ -52,7 +52,10 @@ export const layer = Layer.effect( Message.user(input.prompt), ], tools: Object.fromEntries( - toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]), + toolDefinitions.map((tool) => [ + tool.name, + { description: tool.description, input: { ...tool.inputSchema } }, + ]), ), }) const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => { diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index ecc6bf6257a..3a256e043a8 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -355,8 +355,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { SessionMessage.ToolStateRunning.make({ status: "running", input: event.data.input, - structured: {}, - content: [], + metadata: {}, }), ) } @@ -366,11 +365,12 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) if (match && match.state.status === "running") { - match.state.structured = event.data.structured - match.state.content = [...event.data.content] + match.state.metadata = event.data.metadata } }) }, + // Terminal tool events are self-contained; projection is a direct copy and + // never reaches into ephemeral progress history. "session.tool.success": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) @@ -382,9 +382,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { SessionMessage.ToolStateCompleted.make({ status: "completed", input: match.state.input, - structured: event.data.structured, - content: [...event.data.content], - result: event.data.result, + content: event.data.content, + ...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }), }), ) } @@ -402,9 +401,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { status: "error", error: event.data.error, input: typeof match.state.input === "string" ? {} : match.state.input, - structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}), - content: event.data.content ?? (match.state.status === "running" ? match.state.content : []), - result: event.data.result, + ...(event.data.content === undefined ? {} : { content: event.data.content }), + ...(event.data.metadata === undefined ? {} : { metadata: event.data.metadata }), }), ) } diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 6afe3318ac8..f7fcb95adb0 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -14,13 +14,15 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps" import PROMPT_DEFAULT from "./runner/prompt/base.txt" import { toLLMMessages } from "./runner/to-llm-message" -type ToolCallResolution = - | { readonly type: "reject"; readonly error: SessionError.Error } - | { readonly type: "settle"; readonly settle: ToolRegistry.Materialization["settle"] } - interface Prepared { readonly request: LLMRequest - readonly resolveToolCall: (name: string) => ToolCallResolution + /** + * One request-scoped execution operation. Unknown, hook-removed, and + * step-limit-violating calls fail individually through the same seam. + */ + readonly executeTool: ToolRegistry.ToolSet["execute"] + /** True when this request is the final Step; violating calls are rejected and no continuation follows. */ + readonly stepLimitReached: boolean } interface PrepareInput { @@ -94,14 +96,16 @@ export const layer = Layer.effect( const model = resolved.model const providerMetadataKey = model.route.providerMetadataKey ?? model.provider const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps - const executableTools = stepLimitReached ? undefined : yield* registry.materialize(agent.info.permissions) + // The final Step keeps definitions available to protocols with native "none", + // preserving their prompt cache prefix. Calls are still rejected at execution. + const toolSet = yield* registry.snapshot(agent.info.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial] .filter((part) => part.length > 0) .map(SystemPart.make) const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey) const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history - const toolDefinitions = executableTools?.definitions ?? [] + const toolDefinitions = toolSet.definitions const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool])) // Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit. const contextEvent = yield* hooks.trigger("session", "context", { @@ -131,22 +135,23 @@ export const layer = Layer.effect( tools: hookedTools, toolChoice: stepLimitReached ? "none" : undefined, }) - const resolveToolCall = (name: string): ToolCallResolution => { - if (!executableTools) - return { - type: "reject", + const executeTool: ToolRegistry.ToolSet["execute"] = (executeInput) => { + if (stepLimitReached) + return Effect.succeed({ + status: "error", error: { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" }, - } - if (toolsByName.has(name) && !Object.hasOwn(contextEvent.tools, name)) - return { - type: "reject", - error: { type: "tool.execution", message: `Tool is not available for this request: ${name}` }, - } - return { type: "settle", settle: executableTools.settle } + }) + if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name)) + return Effect.succeed({ + status: "error", + error: { type: "tool.unknown", message: `Tool is not available for this request: ${executeInput.call.name}` }, + }) + return toolSet.execute(executeInput) } return { request, - resolveToolCall, + executeTool, + stepLimitReached, } }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index ad792ce1c74..3d92a5c8134 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -144,21 +144,18 @@ const layer = Layer.effect( } yield* publish(event) if (LLMEvent.is.toolInputError(event)) { - if (prepared.resolveToolCall(event.name).type === "settle") needsContinuation = true + if (!prepared.stepLimitReached) needsContinuation = true return } if (event.type !== "tool-call" || event.providerExecuted) return - const tool = prepared.resolveToolCall(event.name) - if (tool.type === "reject") { - yield* serialized(publisher.failUnsettledTools(tool.error)) - return - } - needsContinuation = true + // Unavailable calls fail individually through the same execution seam; + // continuation depends only on remaining Step allowance. + if (!prepared.stepLimitReached) needsContinuation = true const assistantMessageID = yield* publisher.assistantMessageID(event.id) ownedToolFibers.push( yield* Effect.uninterruptibleMask((restore) => restore( - tool.settle({ + prepared.executeTool({ sessionID: session.id, agent: agent.id, messageID: assistantMessageID, @@ -166,17 +163,7 @@ const layer = Layer.effect( progress: (update) => serialized(publisher.progress(event.id, update)), }), ).pipe( - Effect.flatMap((settlement) => - publish( - LLMEvent.toolResult({ - id: event.id, - name: event.name, - result: settlement.result, - output: settlement.output, - }), - settlement.error, - ), - ), + Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))), ), ).pipe(FiberSet.run(toolFibers)), ) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index df3e9508959..f84253c39d2 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -1,5 +1,5 @@ -import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai" -import { Effect } from "effect" +import { type LLMEvent, type ProviderMetadata, type ToolContent, type ToolResultValue } from "@opencode-ai/ai" +import { Effect, Schema } from "effect" import { EventV2 } from "../../event" import { ModelV2 } from "../../model" import { SessionEvent } from "../event" @@ -11,6 +11,8 @@ import { AgentV2 } from "../../agent" import { Snapshot } from "../../snapshot" import { RelativePath } from "../../schema" import { SessionUsage } from "../usage" +import { Tool } from "../../tool/tool" +import { MAX_BYTES } from "../../tool-output-store" import type { ToolRegistry } from "../../tool/registry" type Input = { @@ -25,24 +27,11 @@ type Input = { const record = (value: unknown): Record => typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : { value } -const message = (value: unknown) => { - if (typeof value === "string") return value - try { - return JSON.stringify(value) ?? String(value) - } catch { - return String(value) - } -} - -type SettledOutput = - | { readonly structured: Record; readonly content: ToolOutput["content"] } - | { readonly error: SessionError.Error } - -const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => { - if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } } - const settled = value ?? ToolOutput.fromResultValue(result) - if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`) - return { structured: record(settled.structured), content: settled.content } +/** Derives canonical model content from a provider-hosted tool result. */ +const hostedContent = (result: ToolResultValue): readonly [ToolContent, ...ToolContent[]] => { + if (result.type === "content" && result.value.length > 0) + return result.value as unknown as readonly [ToolContent, ...ToolContent[]] + return [{ type: "text", text: Tool.stringify(result.value) }] } /** Persist one step without executing tools or starting a continuation step. */ @@ -60,11 +49,8 @@ export const createLLMEventPublisher = (events: Pick() const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => { if (!tool.progress) return {} - const first = tool.progress.content[0] - return { - ...(first === undefined ? {} : { content: [first, ...tool.progress.content.slice(1)] as const }), - metadata: tool.progress.structured, - } + const metadata = Tool.jsonMetadata(tool.progress, MAX_BYTES) + return metadata === undefined ? {} : { metadata } } let assistantMessageID = input.assistantMessageID let stepStarted = false @@ -254,11 +240,7 @@ export const createLLMEventPublisher = (events: Pick ${event.name}`)) if (tool.settled) { + // A late error result is a benign straggler (e.g. after an abort + // sweep); a late success would mean double execution, so it dies. if (event.result.type === "error") return return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`)) } tool.settled = true - const result = error ? { error } : settledOutput(event.output, event.result) const executed = event.providerExecuted === true || tool.providerExecuted const resultState = providerState(event.providerMetadata) - if ("error" in result) { + if (error !== undefined || event.result.type === "error") { yield* events.publish(SessionEvent.Tool.Failed, { sessionID: input.sessionID, assistantMessageID: tool.assistantMessageID, callID: event.id, - error: result.error, + error: error ?? { type: "tool.execution", message: Tool.stringify(event.result.value) }, ...failureSnapshot(tool), - result: event.result, executed, resultState, }) @@ -438,8 +421,7 @@ export const createLLMEventPublisher = (events: Pick ${name}`)) + if (tool.settled) { + if (execution.status === "error") return + return yield* Effect.die(new Error(`Duplicate tool execution: ${callID}`)) + } + tool.settled = true + if (execution.status === "completed") { + yield* events.publish(SessionEvent.Tool.Success, { + sessionID: input.sessionID, + assistantMessageID: tool.assistantMessageID, + callID, + content: execution.content, + ...(execution.metadata === undefined ? {} : { metadata: execution.metadata }), + executed: tool.providerExecuted, + }) + return + } + // An execution-provided snapshot wins; otherwise fall back to retained progress. + const snapshot = + execution.content !== undefined || execution.metadata !== undefined + ? { + ...(execution.content === undefined ? {} : { content: execution.content }), + ...(execution.metadata === undefined ? {} : { metadata: execution.metadata }), + } + : failureSnapshot(tool) + yield* events.publish(SessionEvent.Tool.Failed, { + sessionID: input.sessionID, + assistantMessageID: tool.assistantMessageID, + callID, + error: execution.error, + ...snapshot, + executed: tool.providerExecuted, }) }) return { publish, progress, + toolExecution, flush, failAssistant, publishStepFailure, diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 1c875387d0b..bf115c4ff1e 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -1,11 +1,4 @@ -import { - Message, - ToolCallPart, - ToolOutput, - ToolResultPart, - type ContentPart, - type ProviderMetadata, -} from "@opencode-ai/ai" +import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai" import { Option, Schema } from "effect" import type { ModelV2 } from "../../model" import { SessionMessage } from "../message" @@ -90,15 +83,15 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => { if (tool.state.status === "completed") { // TODO: Materialize remote and managed URIs before provider-history lowering. - // ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes. - const result = - tool.executed === true && tool.state.result !== undefined - ? tool.state.result - : ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content }) + const content = tool.state.content + const single = content.length === 1 ? content[0] : undefined return ToolResultPart.make({ id: tool.id, name: tool.name, - result, + result: + single?.type === "text" + ? { type: "text" as const, value: single.text } + : { type: "content" as const, value: content }, providerExecuted: tool.executed, providerMetadata, }) @@ -107,10 +100,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid return ToolResultPart.make({ id: tool.id, name: tool.name, - result: - tool.executed === true && tool.state.result !== undefined - ? tool.state.result - : { error: tool.state.error, content: tool.state.content, structured: tool.state.structured }, + result: { error: tool.state.error, content: tool.state.content ?? [] }, resultType: "error", providerExecuted: tool.executed, providerMetadata, @@ -119,8 +109,8 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid } const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => { - const sameModel = - String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id) + const sameProvider = String(message.model.providerID) === String(model.providerID) + const sameModel = sameProvider && String(message.model.id) === String(model.id) const reuseProviderMetadata = sameModel && message.error === undefined const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] @@ -138,19 +128,21 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid : [] const reuseToolProviderMetadata = reuseProviderMetadata || - (sameModel && - item.executed === true && - (item.state.status === "completed" || (item.state.status === "error" && item.state.result !== undefined))) + (sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error")) const call = toolCall( item, reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined, ) if (item.executed !== true) return [call] + // Hosted result payloads are provider-format state, not model state: + // replay must survive a model switch within the same provider. const result = toolResult( item, reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState) - : undefined, + : sameProvider && item.executed === true && item.providerResultState !== undefined + ? providerMetadata(providerMetadataKey, item.providerResultState) + : undefined, ) return result ? [call, result] : [call] }) diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index e3ad94fd10c..fe21051d8ab 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -39,8 +39,13 @@ export function toSessionError(cause: unknown): SessionError.Error { } if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message } if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message } - if (cause instanceof ToolFailure || cause instanceof Tool.Failure) - return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error) + if (cause instanceof ToolFailure || cause instanceof Tool.Failure) { + if (cause.error === undefined) return { type: "tool.execution", message: cause.message } + // The canonical error is the sole model-visible representation, so a cause + // with no message must not erase the tool's curated failure message. + const unwrapped = toSessionError(cause.error) + return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped + } if (cause instanceof StepFailedError) return cause.error if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message } if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message } diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index f7eccefea03..d59a342159f 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -8,7 +8,7 @@ import { Global } from "@opencode-ai/util/global" import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node" import { SessionSchema } from "./session/schema" import { Identifier } from "./util/identifier" -import type { ToolOutput } from "@opencode-ai/ai" +import type { ToolContent } from "@opencode-ai/ai" export const MAX_LINES = 2_000 export const MAX_BYTES = 50 * 1024 @@ -19,11 +19,11 @@ export const MANAGED_DIRECTORY = "tool-output" export interface BoundInput { readonly sessionID: SessionSchema.ID readonly callID: string - readonly output: ToolOutput + readonly content: ReadonlyArray } export interface BoundResult { - readonly output: ToolOutput + readonly content: ReadonlyArray readonly outputPaths: ReadonlyArray } @@ -137,21 +137,14 @@ const layer = Layer.effect( const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) { const outputLimits = yield* limits() - const media = input.output.content.filter((item) => item.type === "file") - const text = input.output.content.filter((item) => item.type === "text") - const contextual = - input.output.content.length === 0 - ? yield* Effect.try({ - try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured), - catch: (cause) => new StorageError({ operation: "encode", cause }), - }) - : text.map((item) => item.text).join("") + const media = input.content.filter((item) => item.type === "file") + const contextual = input.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("") if ( lineCount(contextual) <= outputLimits.maxLines && Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes ) return { - output: input.output, + content: input.content, outputPaths: [], } @@ -159,16 +152,13 @@ const layer = Layer.effect( const marker = `... output truncated; full content saved to ${outputPath} ...` return { - output: { - structured: input.output.structured, - content: [ - { - type: "text" as const, - text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes), - }, - ...media, - ], - }, + content: [ + { + type: "text" as const, + text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes), + }, + ...media, + ], outputPaths: [outputPath], } }) diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index beb838a567e..7c1e3f15069 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -1,26 +1,26 @@ # Core Tool Architecture -This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement. +This folder owns Core's local tools, Location-scoped registrations, effective lookup, execution, and terminal outcomes. ## Representations -- `tool.ts` defines the structural canonical `Tool.make({ description, input, output, execute, toModelOutput })` declaration. Shipped built-ins and plugin tools use the same type. +- `tool.ts` defines the structural canonical `Tool.make({ description, input, output?, execute })` tool. Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same type. - `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers. -- `registry.ts` stores only canonical Location registrations, derives definitions, invokes tools, and applies generic output bounding. +- `registry.ts` stores only canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding. Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path. ## Construction -Tool schemas and projection use `input` and `output` terminology. A tool value carries its schemas, executor, projection, and optional catalog permission directly so separately loaded plugin package instances can exchange it structurally. +Tool schemas use `input` and `output` terminology. A tool carries schemas and executable behavior without public identity. A registration binds its name, namespace, CodeMode placement, and optional catalog permission action. Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context: ```ts const source = { type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, } ``` @@ -42,13 +42,13 @@ Registrations are scoped: ## Permissions -The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action. +The registry has no `PermissionV2.Service` dependency and performs no execution authorization. Registration options may attach a permission action solely to preserve whole-tool definition filtering. Most registrations default to their effective name; `edit`, `write`, and `patch` use the shared `edit` action. -Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement. +Tool filtering is catalog visibility, not execution authorization. A call still executes the captured tool's leaf policy if it reaches execution. ## Output -Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths. +Built-ins return complete tool responses. `ToolRegistry.ToolSet.execute` is the only local execution and generic model-output bounding boundary and owns managed retention paths. Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`. diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 0456195dd99..00b49ca0d0a 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -97,15 +97,11 @@ export const Plugin = { .transform((draft) => draft.add( name, - Tool.withPermission( - Tool.make({ + Tool.make({ description: "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", input: Input, output: Output, - toModelOutput: ({ input, output }) => [ - { type: "text", text: toModelOutput(output, input.oldString, input.newString) }, - ], execute: (input, context) => { const unableToEdit = (effect: Effect.Effect) => effect.pipe( @@ -207,12 +203,16 @@ export const Plugin = { ], replacements, } satisfies Output - }) + }).pipe( + Effect.map((output) => ({ + output, + content: toModelOutput(output, input.oldString, input.newString), + metadata: { files: output.files }, + })), + ) }, }), - "edit", - ), - { codemode: false }, + { codemode: false, permission: "edit" }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index b9ffa751e3f..30ca66712cf 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -1,9 +1,10 @@ export * as ExecuteTool from "./execute" +export type { Registration } from "./tool" import { CodeMode, Tool, toolError } from "@opencode-ai/codemode" -import { ToolOutput } from "@opencode-ai/ai" +import type { ToolContent } from "@opencode-ai/ai" import { Effect, Ref, Schema } from "effect" -import { definition, make, settle, type AnyTool } from "./tool" +import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool" const ExecuteFile = Schema.Struct({ data: Schema.String, @@ -14,16 +15,11 @@ const ExecuteFile = Schema.Struct({ const ExecuteCall = Schema.Struct({ tool: Schema.String, status: Schema.Literals(["running", "completed", "error"]), - input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)), }) type ExecuteCall = typeof ExecuteCall.Type -const ExecuteMetadata = Schema.Struct({ - toolCalls: Schema.Array(ExecuteCall), - error: Schema.optionalKey(Schema.Literal(true)), -}) - const ExecuteOutput = Schema.Struct({ output: Schema.String, toolCalls: Schema.Array(ExecuteCall), @@ -36,12 +32,6 @@ type CollectedFiles = { readonly files: Array } -export interface Registration { - readonly tool: AnyTool - readonly name: string - readonly namespace?: string -} - // Invariant model-facing guidance; the changing tool catalog is delivered through Instructions. const description = [ "Run JavaScript in a confined Code Mode runtime through { code }.", @@ -55,20 +45,6 @@ export const create = (registrations: ReadonlyMap) => { description, input: CodeMode.Input, output: ExecuteOutput, - structured: ExecuteMetadata, - toStructuredOutput: ({ output }) => ({ - toolCalls: output.toolCalls, - ...(output.error ? { error: true as const } : {}), - }), - toModelOutput: ({ output }) => [ - { type: "text" as const, text: output.output }, - ...output.files.map((file) => ({ - type: "file" as const, - data: file.data, - mime: file.mime, - ...(file.name === undefined ? {} : { name: file.name }), - })), - ], execute: ({ code }, context) => Effect.gen(function* () { const callIndex = yield* Ref.make(0) @@ -85,21 +61,17 @@ export const create = (registrations: ReadonlyMap) => { (name, registration, input) => Effect.gen(function* () { const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) - const output = yield* settle( - registration.tool, - { type: "tool-call", id: context.callID, name, input }, - { - sessionID: context.sessionID, - agent: context.agent, - messageID: context.messageID, - callID: context.callID, - progress: context.progress, - }, - ).pipe(Effect.mapError((failure) => toolError(failure.message, failure))) - const outputFileParts = outputFiles(output) + const executed = yield* execute(registration.tool, input, { + sessionID: context.sessionID, + agent: context.agent, + messageID: context.messageID, + callID: context.callID, + progress: context.progress, + }).pipe(Effect.mapError((failure) => toolError(failure.message, failure))) + const outputFileParts = outputFiles(executed.content) if (outputFileParts.length > 0) yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }]) - return output.structured + return executed.output }), { onToolCallStart: ({ index, name, input }) => @@ -126,7 +98,30 @@ export const create = (registrations: ReadonlyMap) => { .toSorted((left, right) => left.index - right.index) .flatMap((item) => item.files) const output = formatResult(result) - return { output, toolCalls, files: collected, ...(result.ok ? {} : { error: true as const }) } + const value: typeof ExecuteOutput.Type = { + output, + toolCalls, + files: collected, + ...(result.ok ? {} : { error: true }), + } + const content: [Content, ...Content[]] = [{ type: "text", text: value.output }] + content.push( + ...value.files.map((file) => ({ + type: "file" as const, + data: file.data, + mime: file.mime, + ...(file.name === undefined ? {} : { name: file.name }), + })), + ) + const metadata: Metadata = { + toolCalls: value.toolCalls, + ...(value.error ? { error: true } : {}), + } + return { + output: value, + content, + metadata, + } }), }) } @@ -137,28 +132,30 @@ export const instructions = (registrations: ReadonlyMap) = function runtime( registrations: ReadonlyMap, - invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect, + executeTool: (name: string, registration: Registration, input: unknown) => Effect.Effect, hooks?: CodeMode.ToolCallHooks, ) { - const tools: Record> = {} + const tools: Record> = {} for (const [name, registration] of registrations) { - const child = definition(name, registration.tool) - const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}` + const child = toLLMDefinition(name, registration.tool) + const path = + registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}` tools[path] = Tool.make({ description: child.description, input: child.inputSchema, output: child.outputSchema, - run: (input) => invoke(name, registration, input), + execute: (input) => executeTool(name, registration, input), }) } return CodeMode.make({ tools, ...hooks }) } -function displayInput(input: unknown): Record | undefined { +// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact. +function displayInput(input: unknown): Record | undefined { if (input === null || input === undefined) return - if (typeof input !== "object" || Array.isArray(input)) return { input } + if (typeof input !== "object" || Array.isArray(input)) return { input: input as typeof Schema.Json.Type } if (Object.keys(input).length === 0) return - return input as Record + return input as Record } function formatResult(result: CodeMode.Result) { @@ -180,8 +177,8 @@ function formatValue(value: CodeMode.DataValue) { return JSON.stringify(value, null, 2) ?? String(value) } -function outputFiles(output: ToolOutput): Array { - return output.content.flatMap((part) => { +function outputFiles(content: ReadonlyArray): Array { + return content.flatMap((part) => { if (part.type !== "file") return [] const prefix = `data:${part.mime};base64,` if (!part.uri.startsWith(prefix)) return [] diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 27a7d26a010..50ef6853942 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -8,7 +8,7 @@ import { FileSystem } from "../filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "../location" import { Ripgrep } from "../ripgrep" -import { NonNegativeInt, RelativePath } from "../schema" +import { RelativePath } from "../schema" import { PermissionV2 } from "../permission" import { Tool } from "./tool" @@ -25,9 +25,6 @@ export const Input = Schema.Struct({ }) export const Output = Schema.Array(FileSystem.Entry) -const StructuredOutput = Schema.Struct({ - count: NonNegativeInt, -}) type ModelOutput = typeof Output.Encoded /** Format raw search results into the concise line-oriented output models expect. */ @@ -54,16 +51,6 @@ export const Plugin = { "Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.", input: Input, output: Output, - structured: StructuredOutput, - toStructuredOutput: ({ output }) => ({ count: output.length }), - toModelOutput: ({ output }) => [ - { - type: "text", - text: toModelOutput( - output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })), - ), - }, - ], execute: (input, context) => Effect.gen(function* () { yield* permission.assert({ @@ -104,6 +91,13 @@ export const Plugin = { ), ) }).pipe( + Effect.map((output) => ({ + output, + content: toModelOutput( + output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })), + ), + metadata: { count: output.length }, + })), Effect.mapError((error) => error instanceof ToolFailure ? error diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 56cb004ddf8..fa7326302d8 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "../location" import { PermissionV2 } from "../permission" import { Ripgrep } from "../ripgrep" -import { NonNegativeInt, RelativePath } from "../schema" +import { RelativePath } from "../schema" import { Tool } from "./tool" export const name = "grep" @@ -30,9 +30,6 @@ export const Input = Schema.Struct({ }) export const Output = Schema.Array(FileSystem.Match) -const StructuredOutput = Schema.Struct({ - matches: NonNegativeInt, -}) type ModelOutput = typeof Output.Encoded /** Format raw search matches into the familiar concise model output. */ @@ -68,19 +65,6 @@ export const Plugin = { "Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.", input: Input, output: Output, - structured: StructuredOutput, - toStructuredOutput: ({ output }) => ({ matches: output.length }), - toModelOutput: ({ output }) => [ - { - type: "text", - text: toModelOutput( - output.map((match) => ({ - ...match, - entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) }, - })), - ), - }, - ], execute: (input, context) => Effect.gen(function* () { yield* permission.assert({ @@ -135,6 +119,16 @@ export const Plugin = { ), ) }).pipe( + Effect.map((output) => ({ + output, + content: toModelOutput( + output.map((match) => ({ + ...match, + entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) }, + })), + ), + metadata: { matches: output.length }, + })), Effect.mapError((error) => error instanceof ToolFailure ? error diff --git a/packages/core/src/tool/hooks.ts b/packages/core/src/tool/hooks.ts index 5a0c93c3149..2e6cf32942a 100644 --- a/packages/core/src/tool/hooks.ts +++ b/packages/core/src/tool/hooks.ts @@ -1,33 +1,14 @@ export * as ToolHooks from "./hooks" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { Agent } from "@opencode-ai/schema/agent" -import { Session } from "@opencode-ai/schema/session" -import { SessionMessage } from "../session/message" import { State } from "../state" import { Context, Effect, Layer, Scope } from "effect" -import type { ToolOutput, ToolResultValue } from "@opencode-ai/ai" +import type { Tool } from "./tool" -export interface BeforeEvent { - readonly tool: string - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly messageID: SessionMessage.ID - readonly callID: string - input: unknown -} +export type BeforeEvent = Tool.ToolExecuteBeforeEvent -export interface AfterEvent { - readonly tool: string - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly messageID: SessionMessage.ID - readonly callID: string - readonly input: unknown - result: ToolResultValue - output?: ToolOutput - outputPaths?: ReadonlyArray -} +/** The canonical execution outcome. Hooks never observe the raw domain output. */ +export type AfterEvent = Tool.ToolExecuteAfterEvent export interface Interface { readonly hook: { diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index de9910a8197..24b10f79dad 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -32,86 +32,90 @@ export const layer = Layer.effectDiscard( // registry never has a gap where MCP tools disappear mid-swap. const reconcile = lock.withPermit( Effect.gen(function* () { - const groups = new Map; codemode: boolean }>() + const groups = new Map< + string, + { + tools: Record + codemode: boolean + } + >() for (const tool of yield* mcp.tools()) { const group = groups.get(tool.server) ?? { tools: {}, codemode: tool.codemode !== false } const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema - group.tools[tool.name] = Tool.withPermission( - Tool.make({ - description: tool.description ?? "", - jsonSchema: { - ...schema, - type: "object", - properties: schema.properties ?? {}, - additionalProperties: false, - }, - outputSchema: tool.outputSchema as JsonSchema.JsonSchema | undefined, - execute: (input, context) => - Effect.gen(function* () { - yield* permission.assert({ - action: name(tool.server, tool.name), - resources: ["*"], - save: ["*"], - metadata: {}, - sessionID: context.sessionID, - agent: context.agent, - source: { - type: "tool", - messageID: context.messageID, - callID: context.callID, - }, + group.tools[tool.name] = Tool.make({ + description: tool.description ?? "", + input: { + ...schema, + type: "object", + properties: schema.properties ?? {}, + additionalProperties: false, + }, + output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema, + execute: (input, context) => + Effect.gen(function* () { + yield* permission.assert({ + action: name(tool.server, tool.name), + resources: ["*"], + save: ["*"], + metadata: {}, + sessionID: context.sessionID, + agent: context.agent, + source: { + type: "tool", + messageID: context.messageID, + callID: context.callID, + }, + }) + const result = yield* mcp + .callTool({ + server: tool.server, + name: tool.name, + args: (input ?? {}) as Record, }) - const result = yield* mcp - .callTool({ - server: tool.server, - name: tool.name, - args: (input ?? {}) as Record, - }) - .pipe( - Effect.catchTags({ - "MCP.NotFoundError": (error) => - new ToolFailure({ message: `MCP server "${error.server}" is not available` }), - "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), - }), - ) - if (result.isError) - return yield* new ToolFailure({ - message: - result.content - .flatMap((part) => (part.type === "text" ? [part.text] : [])) - .join("\n") - .trim() || "MCP tool returned an error", - }) - const content = result.content.map((part) => - part.type === "text" - ? { type: "text" as const, text: part.text } - : { type: "file" as const, data: part.data, mime: part.mimeType }, + .pipe( + Effect.catchTags({ + "MCP.NotFoundError": (error) => + new ToolFailure({ message: `MCP server "${error.server}" is not available` }), + "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), + }), ) - const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") - return { - structured: result.structured ?? (text === "" ? null : text), - content, - } - }).pipe( - Effect.mapError((error) => - error instanceof ToolFailure - ? error - : new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }), - ), + if (result.isError) + return yield* new ToolFailure({ + message: + result.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join("\n") + .trim() || "MCP tool returned an error", + }) + const content = result.content.map((part) => + part.type === "text" + ? { type: "text" as const, text: part.text } + : { type: "file" as const, data: part.data, mime: part.mimeType }, + ) + const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") + return { + output: result.structured ?? (text === "" ? null : text), + ...(content.length === 0 ? {} : { content: content as [Tool.Content, ...Tool.Content[]] }), + } + }).pipe( + Effect.mapError((error) => + error instanceof ToolFailure + ? error + : new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }), ), - }), - name(tool.server, tool.name), - ) + ), + }) groups.set(tool.server, group) } const next = yield* Scope.fork(scope) - yield* Effect.forEach( - groups, - ([server, group]) => tools.register(group.tools, { namespace: namespace(server), codemode: group.codemode }), - { - discard: true, - }, - ).pipe(Scope.provide(next), Effect.orDie) + yield* tools + .registerBatch( + Array.from(groups, ([server, group]) => ({ + tools: group.tools, + options: { namespace: namespace(server), codemode: group.codemode }, + })), + ) + .pipe(Scope.provide(next), Effect.orDie) if (current) yield* Scope.close(current, Exit.void) current = next }), diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 7a70b3c5c33..5e90c355966 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -75,12 +75,10 @@ export const Plugin = { .transform((draft) => draft.add( name, - Tool.withPermission( - Tool.make({ + Tool.make({ description: DESCRIPTION, input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => { const applied: Array = [] const fail = (path: string, error?: unknown) => { @@ -278,12 +276,17 @@ export const Plugin = { { discard: true }, ) return { applied, files: patchFiles } - }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error)))) + }).pipe( + Effect.map((output) => ({ + output, + content: toModelOutput(output), + metadata: { files: output.files }, + })), + Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))), + ) }, }), - "edit", - ), - { codemode: false }, + { codemode: false, permission: "edit" }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index aa343c96529..cdb80a0000f 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -63,9 +63,6 @@ export const Plugin = { description, input: Input, output: Output, - toModelOutput: ({ input, output }) => [ - { type: "text", text: toModelOutput(input.questions, output.answers) }, - ], execute: (input, context) => permission .assert({ @@ -95,13 +92,18 @@ export const Plugin = { ), Effect.flatMap((state) => { if (state.status === "cancelled") return Effect.die(new CancelledError()) - return Effect.succeed({ + const output = { answers: input.questions.map((_, index): QuestionV2.Answer => { const value = state.answer[`q${index}`] if (value === undefined) return [] if (typeof value === "object") return Array.from(value) return [String(value)] }), + } + return Effect.succeed({ + output, + content: toModelOutput(input.questions, output.answers), + metadata: { answers: output.answers }, }) }), ), diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 4021516cc4a..5c0b9254beb 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -48,20 +48,6 @@ export const Plugin = { "Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page. Relative paths resolve from the current location; absolute paths inside it are accepted, while external absolute paths require external_directory approval.", input: Input, output: Output, - structured: Schema.toEncoded(Output), - // Image base64 reaches the model through content items (normalized generically - // at tool settlement); persisting a second copy in structured would store the - // original unresized bytes in the message row. - toStructuredOutput: ({ output }) => - "encoding" in output && output.encoding === "base64" ? { ...output, content: "" } : output, - toModelOutput: ({ input, output }) => { - if (!("encoding" in output) || output.encoding !== "base64" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) - return [] - return [ - { type: "text", text: "Image read successfully" }, - { type: "file", data: output.content, mime: output.mime, name: input.path }, - ] - }, execute: (input, context) => { return Effect.gen(function* () { const source = { @@ -125,6 +111,20 @@ export const Plugin = { return yield* Effect.fail(new ReadToolFileSystem.BinaryFileError({ resource })) return content }).pipe( + Effect.map((output) => { + // Image base64 reaches the model through content items; avoid a second + // unresized copy in model text. + const content = + "encoding" in output && output.encoding === "base64" + ? SUPPORTED_IMAGE_MIMES.has(output.mime) + ? ([ + { type: "text", text: "Image read successfully" }, + { type: "file", data: output.content, mime: output.mime, name: input.path }, + ] as const) + : JSON.stringify({ ...output, content: "" }, null, 2) + : JSON.stringify(output, null, 2) + return { output, content } + }), Effect.mapError((error) => { const message = error instanceof ReadToolFileSystem.BinaryFileError || diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 1dd10038df7..9316f6f8f6e 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -1,7 +1,7 @@ export * as ToolRegistry from "./registry" -import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/ai" -import { Context, Effect, Layer, Scope, Semaphore } from "effect" +import { type ToolCall, type ToolContent, type ToolDefinition } from "@opencode-ai/ai" +import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect" import type { AgentV2 } from "../agent" import { Image } from "../image" import { PermissionV2 } from "../permission" @@ -10,19 +10,10 @@ import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" import { CodeMode } from "../codemode" -import { - definition, - permission, - registrationEntries, - RegistrationError, - settle, - validateNamespace, - type AnyTool, -} from "./tool" +import { Tool, nonEmpty, registrationEntries, toLLMDefinition, validateName, validateNamespace } from "./tool" import { Tools } from "./tools" import { ToolHooks } from "./hooks" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { SessionError } from "@opencode-ai/schema/session-error" import { toSessionError } from "../session/to-session-error" export type ExecuteInput = { @@ -33,38 +24,42 @@ export type ExecuteInput = { readonly progress?: (update: Progress) => Effect.Effect } -export interface Progress { - readonly structured: Readonly> - readonly content: ToolOutput["content"] -} +/** Live replacement metadata for a running tool. */ +export type Progress = Tool.Metadata export interface Interface { - readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect + readonly snapshot: (permissions?: PermissionV2.Ruleset) => Effect.Effect /** Internal registration capability exposed publicly only through Tools.Service. */ readonly register: ( - tools: Readonly>, + tools: Readonly>, options?: Tools.RegisterOptions, - ) => Effect.Effect + ) => Effect.Effect /** Internal atomic registration capability used by plugin transforms. */ readonly registerBatch: ( registrations: ReadonlyArray<{ - readonly tools: Readonly> + readonly tools: Readonly> readonly options?: Tools.RegisterOptions }>, - ) => Effect.Effect + ) => Effect.Effect } -export interface Materialization { +/** + * One request-scoped snapshot pairing advertised definitions with captured + * tools. A model request executes exactly the tool values it advertised + * even if registration changes while the request is in flight. + */ +export interface ToolSet { readonly definitions: ReadonlyArray - readonly settle: (input: ExecuteInput) => Effect.Effect + readonly execute: (input: ExecuteInput) => Effect.Effect } -export interface Settlement { - readonly result: ToolResultValue - readonly output?: ToolOutput - readonly outputPaths?: ReadonlyArray - readonly error?: SessionError.Error -} +/** + * The canonical outcome of one local tool execution. `output` is the validated + * machine value for Code Mode and remains ephemeral; durable publication drops it. + */ +export type ToolOutcome = + | (Extract & { readonly output?: unknown }) + | Extract export class Service extends Context.Service()("@opencode/v2/ToolRegistry") {} @@ -76,26 +71,24 @@ const registryLayer = Layer.effect( const image = yield* Image.Service const codeMode = yield* CodeMode.Service - type NormalizedItem = ToolOutput["content"][number] | "decode" | "size" - const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) { + type NormalizedItem = ToolContent | "decode" | "size" + const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ReadonlyArray) { const normalized = yield* Effect.forEach(content, (item): Effect.Effect => { if (item.type !== "file" || !item.mime.startsWith("image/")) return Effect.succeed(item) // RFC 2397 permits parameters between the mime and ";base64". const base64 = /^data:[^,]*;base64,(.*)$/s.exec(item.uri)?.[1] if (base64 === undefined) return Effect.succeed(item) const resource = item.name ?? `${item.mime} tool output` - return image - .normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }) - .pipe( - Effect.map((result) => ({ - ...item, - uri: `data:${result.mime};base64,${result.content}`, - mime: result.mime, - })), - Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)), - Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)), - Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)), - ) + return image.normalize(resource, { uri: resource, content: base64, encoding: "base64", mime: item.mime }).pipe( + Effect.map((result) => ({ + ...item, + uri: `data:${result.mime};base64,${result.content}`, + mime: result.mime, + })), + Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(item)), + Effect.catchTag("Image.DecodeError", () => Effect.succeed("decode" as const)), + Effect.catchTag("Image.SizeError", () => Effect.succeed("size" as const)), + ) }) const note = (reason: "decode" | "size", text: string) => { const count = normalized.filter((item) => item === reason).length @@ -108,16 +101,24 @@ const registryLayer = Layer.effect( ...note("size", "could not be resized below the image size limit."), ] }) - type Registration = { - readonly tool: AnyTool - readonly name: string - readonly namespace?: string - } + + // Invalid or oversized metadata is dropped with a warning; it never fails a + // successful side-effecting tool. + const validMetadata = Effect.fnUntraced(function* (tool: string, metadata: Tool.Metadata | undefined) { + if (metadata === undefined) return undefined + const limits = yield* resources.limits() + const valid = Tool.jsonMetadata(metadata, limits.maxBytes) + if (valid === undefined) + yield* Effect.logWarning("dropping invalid or oversized tool metadata").pipe(Effect.annotateLogs({ tool })) + return valid + }) + + type Registration = Tool.Registration const local = new Map>() const registrationLock = Semaphore.makeUnsafe(1) - const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) { - // Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool. + const executeTool = Effect.fn("ToolRegistry.executeTool")(function* (input: ExecuteInput, tool: Tool.Any) { + // Hooks fire only for hosted/local tools; provider-executed calls never reach executeTool. const beforeEvent: ToolHooks.BeforeEvent = { tool: input.call.name, sessionID: input.sessionID, @@ -127,76 +128,100 @@ const registryLayer = Layer.effect( input: input.call.input, } yield* toolHooks.runBefore(beforeEvent) - const pending = yield* settle( - tool, - { ...input.call, input: beforeEvent.input }, - { - sessionID: input.sessionID, - agent: input.agent, - messageID: input.messageID, - callID: input.call.id, - progress: (update) => { - const progress = input.progress - if (!progress) return Effect.void - return normalizeImages( - (update.content ?? []).map((part) => - part.type === "text" - ? { type: "text" as const, text: part.text } - : { - type: "file" as const, - uri: `data:${part.mime};base64,${part.data}`, - mime: part.mime, - name: part.name, - }, - ), - ).pipe(Effect.flatMap((content) => progress({ structured: update.structured, content }))) - }, + const execution = yield* Tool.execute(tool, beforeEvent.input, { + sessionID: input.sessionID, + agent: input.agent, + messageID: input.messageID, + callID: input.call.id, + progress: (metadata) => { + const progress = input.progress + if (!progress) return Effect.void + return validMetadata(input.call.name, metadata).pipe( + Effect.flatMap((valid) => (valid === undefined ? Effect.void : progress(valid))), + ) }, - ).pipe( - Effect.map((output) => ({ output })), - Effect.catchTag("LLM.ToolFailure", (failure) => - Effect.succeed({ - result: { type: "error" as const, value: failure.message }, - error: toSessionError(failure), - }), - ), + }).pipe( + Effect.map((value) => ({ value })), + Effect.catchTag("LLM.ToolFailure", (failure) => Effect.succeed({ failure: toSessionError(failure) })), ) - let settlement: Settlement - if ("result" in pending) { - settlement = pending - } else { + + const outcome: ToolOutcome = yield* Effect.gen(function* () { + if ("failure" in execution) return { status: "error" as const, error: execution.failure } const bounded = yield* resources.bound({ sessionID: input.sessionID, callID: input.call.id, - output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) }, + content: yield* normalizeImages(execution.value.content), }) - const result = ToolOutput.toResultValue(bounded.output) - settlement = - result.type === "error" - ? bounded.outputPaths.length > 0 - ? { result, outputPaths: bounded.outputPaths } - : { result } - : bounded.outputPaths.length > 0 - ? { result, output: bounded.output, outputPaths: bounded.outputPaths } - : { result, output: bounded.output } - } - const afterEvent: ToolHooks.AfterEvent = { + const metadata = yield* validMetadata(input.call.name, execution.value.metadata) + return { + status: "completed" as const, + ...(execution.value.output === undefined ? {} : { output: execution.value.output }), + content: nonEmpty(bounded.content) ?? execution.value.content, + ...(metadata === undefined ? {} : { metadata }), + ...(bounded.outputPaths.length > 0 ? { outputPaths: bounded.outputPaths } : {}), + } + }) + + const base = { tool: input.call.name, sessionID: input.sessionID, agent: input.agent, messageID: input.messageID, callID: input.call.id, input: beforeEvent.input, - result: settlement.result, - output: settlement.output, - outputPaths: settlement.outputPaths, } + const afterEvent: ToolHooks.AfterEvent = + outcome.status === "completed" + ? { + ...base, + status: "completed", + content: outcome.content, + ...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }), + ...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }), + } + : { + ...base, + status: "error", + error: outcome.error, + ...(outcome.content === undefined ? {} : { content: outcome.content }), + ...(outcome.metadata === undefined ? {} : { metadata: outcome.metadata }), + ...(outcome.outputPaths === undefined ? {} : { outputPaths: outcome.outputPaths }), + } yield* toolHooks.runAfter(afterEvent) + const afterMetadata = yield* validMetadata(input.call.name, afterEvent.metadata) + const afterContent = yield* Effect.gen(function* () { + if ( + afterEvent.content === undefined || + (outcome.status === "completed" && afterEvent.content === outcome.content) + ) + return { content: afterEvent.content, outputPaths: afterEvent.outputPaths } + const bounded = yield* resources.bound({ + sessionID: input.sessionID, + callID: input.call.id, + content: yield* normalizeImages(afterEvent.content), + }) + return { + content: nonEmpty(bounded.content), + outputPaths: + bounded.outputPaths.length === 0 + ? afterEvent.outputPaths + : Array.from(new Set([...(afterEvent.outputPaths ?? []), ...bounded.outputPaths])), + } + }) + if (afterEvent.status === "completed") + return { + status: "completed" as const, + ...(outcome.status === "completed" && outcome.output !== undefined ? { output: outcome.output } : {}), + content: afterContent.content ?? afterEvent.content, + ...(afterMetadata === undefined ? {} : { metadata: afterMetadata }), + ...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }), + } return { - result: afterEvent.result, - ...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}), - ...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}), - ...(settlement.error !== undefined ? { error: settlement.error } : {}), + status: "error" as const, + error: afterEvent.error, + ...(afterContent.content === undefined ? {} : { content: afterContent.content }), + ...(afterMetadata === undefined ? {} : { metadata: afterMetadata }), + ...(afterContent.outputPaths === undefined ? {} : { outputPaths: afterContent.outputPaths }), } }) @@ -205,12 +230,26 @@ const registryLayer = Layer.effect( const planned = yield* Effect.forEach(registrations, ({ tools, options }) => Effect.gen(function* () { if (options?.namespace !== undefined) yield* validateNamespace(options.namespace) - const entries = registrationEntries(tools, options?.namespace) + const entries = registrationEntries(tools, options) + yield* Effect.forEach(entries, (entry) => validateName(entry.name), { discard: true }) + const collision = entries.find( + (entry, index) => entries.findIndex((candidate) => candidate.key === entry.key) !== index, + ) + if (collision) + return yield* Effect.fail( + new Tool.RegistrationError({ + name: collision.key, + message: `Duplicate normalized tool name: ${collision.key}`, + }), + ) const codemode = options?.codemode ?? true const reserved = codemode ? undefined : entries.find((entry) => entry.key === "execute") if (reserved) return yield* Effect.fail( - new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }), + new Tool.RegistrationError({ + name: reserved.key, + message: 'Tool name "execute" is reserved for CodeMode', + }), ) return { tools, options, entries, codemode } }), @@ -218,7 +257,7 @@ const registryLayer = Layer.effect( // CodeMode registrations live in the CodeMode service; the registry keeps only direct tools. yield* Effect.forEach( planned.filter((plan) => plan.codemode && plan.entries.length > 0), - (plan) => codeMode.register(plan.tools, plan.options), + (plan) => codeMode.register(plan.entries), { discard: true }, ) const direct = planned.filter((plan) => !plan.codemode) @@ -237,6 +276,7 @@ const registryLayer = Layer.effect( tool: entry.tool, name: entry.name, namespace: entry.namespace, + permission: entry.permission, }, }, ]) @@ -269,7 +309,7 @@ const registryLayer = Layer.effect( ]), ), registerBatch, - materialize: Effect.fn("ToolRegistry.materialize")((permissions) => + snapshot: Effect.fn("ToolRegistry.snapshot")((permissions) => registrationLock.withPermit( Effect.gen(function* () { const direct = new Map() @@ -277,21 +317,21 @@ const registryLayer = Layer.effect( for (const [name, entries] of local) { const registration = entries.at(-1)?.registration if (!registration) continue - if (whollyDisabled(permission(registration.tool, name), rules)) continue + if (whollyDisabled(registration.permission, rules)) continue direct.set(name, registration) } - const execute = (yield* codeMode.materialize(permissions)).tool + const codemodeTool = (yield* codeMode.materialize(permissions)).tool return { definitions: [ - ...Array.from(direct, ([name, registration]) => definition(name, registration.tool)), - ...(execute ? [definition("execute", execute)] : []), + ...Array.from(direct, ([name, registration]) => toLLMDefinition(name, registration.tool)), + ...(codemodeTool ? [toLLMDefinition("execute", codemodeTool)] : []), ], - settle: (input: ExecuteInput) => { - if (input.call.name === "execute" && execute) return settleTool(input, execute) + execute: (input: ExecuteInput) => { + if (input.call.name === "execute" && codemodeTool) return executeTool(input, codemodeTool) const registration = direct.get(input.call.name) - if (registration) return settleTool(input, registration.tool) - return Effect.succeed({ - result: { type: "error", value: `Unknown tool: ${input.call.name}` }, + if (registration) return executeTool(input, registration.tool) + return Effect.succeed({ + status: "error", error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` }, }) }, diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 63980f911da..534855a78d6 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -3,7 +3,7 @@ export * as ShellTool from "./shell" import path from "path" import { ToolFailure } from "@opencode-ai/ai" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" -import { Effect, Fiber, Schedule, Schema, Scope } from "effect" +import { Deferred, Effect, Schema, Scope } from "effect" import { FSUtil } from "@opencode-ai/util/fs-util" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" @@ -147,19 +147,6 @@ export const Plugin = { description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, input: Input, output: Output, - structured: StructuredOutput, - toStructuredOutput: ({ output }) => ({ - truncated: output.truncated, - ...(output.exit === undefined ? {} : { exit: output.exit }), - ...(output.shellID === undefined ? {} : { shellID: output.shellID }), - ...(output.timeout === undefined ? {} : { timeout: output.timeout }), - }), - toModelOutput: ({ output }) => { - const parts: Content[] = [{ type: "text", text: output.output }] - const model = modelOutput(output) - if (model) parts.push({ type: "text", text: model }) - return parts - }, execute: (input, context) => Effect.gen(function* () { const source = { @@ -199,6 +186,7 @@ export const Plugin = { timeout, metadata: { sessionID: context.sessionID }, }) + yield* context.progress({ shellID: info.id }) const captureShell = Effect.fn("ShellTool.captureShell")(function* () { const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) @@ -232,7 +220,9 @@ export const Plugin = { } }) + const settled = yield* Deferred.make() const run = settleShell().pipe( + Effect.tap((output) => Deferred.succeed(settled, output)), Effect.map((output) => output.output), Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)), ) @@ -256,32 +246,8 @@ export const Plugin = { } } - let previousProgress: { readonly output: string; readonly truncated: boolean } | undefined - const progress = yield* Effect.sleep("1 second").pipe( - Effect.andThen( - captureShell().pipe( - Effect.flatMap((capture) => - Effect.gen(function* () { - if ( - previousProgress?.output === capture.output && - previousProgress.truncated === capture.truncated - ) - return - previousProgress = capture - yield* context.progress({ - structured: { truncated: capture.truncated }, - content: [{ type: "text", text: capture.output }], - }) - }), - ), - ), - ), - Effect.repeat(Schedule.forever), - Effect.forkIn(scope, { startImmediately: true }), - ) const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe( Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)), - Effect.ensuring(Fiber.interrupt(progress)), ) if (result?.type === "backgrounded") { yield* shell.timeout(info.id, 0) @@ -298,11 +264,23 @@ export const Plugin = { return yield* Effect.fail(new Error(result.info.error ?? "Command failed")) if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled")) - return { - ...(yield* settleShell()), - ...(warnings.length ? { warnings } : {}), - } + return { ...(yield* Deferred.await(settled)), ...(warnings.length ? { warnings } : {}) } }).pipe( + Effect.map((output) => { + const content: [Content, ...Content[]] = [{ type: "text", text: output.output }] + const model = modelOutput(output) + if (model) content.push({ type: "text", text: model }) + return { + output, + content, + metadata: { + truncated: output.truncated, + ...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}), + ...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}), + ...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}), + }, + } + }), Effect.mapError( (error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }), ), diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index dcf0e8bf70e..c3974892040 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -21,11 +21,6 @@ export const Output = Schema.Struct({ directory: Schema.String, output: Schema.String, }) -const StructuredOutput = Schema.Struct({ - name: Output.fields.name, - directory: Output.fields.directory, -}) - export const description = [ "Load a specialized skill when the task at hand matches one of the available skills in the instructions.", "", @@ -70,9 +65,6 @@ export const Plugin = { description, input: Input, output: Output, - structured: StructuredOutput, - toStructuredOutput: ({ output }) => ({ name: output.name, directory: output.directory }), - toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { const current = yield* skills.list() @@ -101,7 +93,13 @@ export const Plugin = { output: toModelOutput(skill, files), } }).pipe(Effect.mapError((error) => unableToLoad(input.id, error))) - }), + }).pipe( + Effect.map((output) => ({ + output, + content: output.output, + metadata: { name: output.name, directory: output.directory }, + })), + ), }), { codemode: false }, ), diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 1f2f2f6c8bd..daef4e1cf39 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -31,11 +31,6 @@ export const Output = Schema.Struct({ status: Schema.Literals(["completed", "running"]), output: Schema.String, }) -const StructuredOutput = Schema.Struct({ - sessionID: Output.fields.sessionID, - status: Output.fields.status, -}) - export const description = [ "Spawn a subagent: a child session running a configured agent with fresh context.", "Foreground (default) runs the subagent to completion and returns its final response.", @@ -119,9 +114,6 @@ export const Plugin = { description, input: Input, output: Output, - structured: StructuredOutput, - toStructuredOutput: ({ output }) => ({ sessionID: output.sessionID, status: output.status }), - toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { const parent = yield* runtime.session @@ -186,7 +178,7 @@ export const Plugin = { const background = input.background === true yield* context.progress({ - structured: { sessionID: child.id, status: "running" }, + metadata: { sessionID: child.id, status: "running" }, }) const run = Effect.gen(function* () { @@ -238,7 +230,13 @@ export const Plugin = { if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" }) return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT } - }), + }).pipe( + Effect.map((output) => ({ + output, + content: output.output, + metadata: { sessionID: output.sessionID, status: output.status }, + })), + ), }), { codemode: false }, ), diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 7525c9c0ddc..e9917f94ddc 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -1,2 +1,90 @@ -export * as Tool from "@opencode-ai/plugin/v2/effect/tool" +export * as Tool from "./tool" export * from "@opencode-ai/plugin/v2/effect/tool" + +import type { ToolContent } from "@opencode-ai/ai" +import { + decodeInput, + encodeOutput, + type Any, + type Content, + type Context, + Failure, + type Metadata, +} from "@opencode-ai/plugin/v2/effect/tool" +import { Effect, Schema } from "effect" + +/** Non-empty canonical model content. */ +export type NonEmptyContent = readonly [ToolContent, ...ToolContent[]] + +/** + * The execution-local result of one tool call: the machine output for + * Code Mode, canonical model content, and optional UI metadata. The typed + * domain output never leaves this function. + */ +export type Execution = { + readonly output?: unknown + readonly content: NonEmptyContent + readonly metadata?: Metadata +} + +export const execute = (tool: Any, input: unknown, context: Context): Effect.Effect => + Effect.gen(function* () { + const decoded = yield* decodeInput(tool.input, input) + const result = yield* tool.execute(decoded, context) + if (tool.output === undefined) { + if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema") + return { + content: contentFrom(result.content), + ...(result.metadata === undefined ? {} : { metadata: result.metadata }), + } + } + if (!("output" in result)) + return yield* Effect.fail(new Failure({ message: "Tool did not return its declared output" })) + const encoded = yield* encodeOutput(tool.output, result.output) + return { + output: encoded, + content: contentFrom(result.content, encoded), + ...(result.metadata === undefined ? {} : { metadata: result.metadata }), + } + }) + +/** Model content from the tool's projection, falling back to the stringified encoded output. */ +const contentFrom = (projected: string | ReadonlyArray | undefined, encoded?: unknown): NonEmptyContent => { + if (typeof projected === "string") return [textContent(projected)] + if (projected !== undefined) { + const mapped = nonEmpty(projected.map(toModelContent)) + if (mapped !== undefined) return mapped + } + return [textContent(stringify(encoded))] +} + +export const toModelContent = (part: Content): ToolContent => + part.type === "text" + ? { type: "text", text: part.text } + : { type: "file", uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name } + +export const nonEmpty = (content: ReadonlyArray): NonEmptyContent | undefined => + content.length > 0 ? (content as NonEmptyContent) : undefined + +const textContent = (text: string): ToolContent => ({ type: "text", text }) + +/** Human-readable text for an arbitrary value; strings pass through unchanged. */ +export const stringify = (value: unknown) => { + if (typeof value === "string") return value + try { + return JSON.stringify(value) ?? String(value) + } catch { + return String(value) + } +} + +const MetadataSchema = Schema.Record(Schema.String, Schema.Json) + +/** Defensive boundary: non-JSON or oversized metadata is dropped, never failing the producing call. */ +export const jsonMetadata = (value: unknown, maxBytes?: number): Metadata | undefined => { + if (value === undefined) return undefined + const decoded = Schema.decodeUnknownOption(MetadataSchema)(value) + if (decoded._tag === "None") return undefined + if (maxBytes !== undefined && Buffer.byteLength(JSON.stringify(decoded.value), "utf-8") > maxBytes) return undefined + return decoded.value +} diff --git a/packages/core/src/tool/tools.ts b/packages/core/src/tool/tools.ts index cfc4dd95dce..31ecc10cdf0 100644 --- a/packages/core/src/tool/tools.ts +++ b/packages/core/src/tool/tools.ts @@ -7,13 +7,13 @@ export type RegisterOptions = Tool.RegisterOptions export interface Interface { readonly register: ( - tools: Readonly>, + tools: Readonly>, options?: Tool.RegisterOptions, ) => Effect.Effect /** Internal atomic registration capability used by plugin transforms. */ readonly registerBatch: ( registrations: ReadonlyArray<{ - readonly tools: Readonly> + readonly tools: Readonly> readonly options?: Tool.RegisterOptions }>, ) => Effect.Effect diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 0ce32509950..d3f894bdbc4 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -37,10 +37,6 @@ const Output = Schema.Struct({ format: Input.fields.format, output: Schema.String, }) -const StructuredOutput = Schema.Struct({ - contentType: Output.fields.contentType, -}) - type Format = (typeof Input.Type)["format"] const acceptHeader = (format: Format) => { @@ -129,9 +125,6 @@ export const Plugin = { description, input: Input, output: Output, - structured: StructuredOutput, - toStructuredOutput: ({ output }) => ({ contentType: output.contentType }), - toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { yield* Effect.try({ @@ -171,12 +164,13 @@ export const Plugin = { try: () => convert(content, contentType, input.format), catch: (error) => error, }) - return { + const result = { url: input.url, contentType, format: input.format, output, } + return { output: result, content: result.output, metadata: { contentType: result.contentType } } }).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))), }), { codemode: false }, diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 49b059c9dff..bc6f1bcfa7d 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -190,10 +190,6 @@ const Output = Schema.Struct({ provider: Provider, text: Schema.String, }) -const StructuredOutput = Schema.Struct({ - provider: Output.fields.provider, -}) - export const Plugin = { id: "opencode.tool.websearch", effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) { @@ -209,9 +205,6 @@ export const Plugin = { description, input: Input, output: Output, - structured: StructuredOutput, - toStructuredOutput: ({ output }) => ({ provider: output.provider }), - toModelOutput: ({ output }) => [{ type: "text", text: output.text }], execute: (input, context) => { const provider = selectProvider(context.sessionID, config, config.provider) return Effect.gen(function* () { @@ -250,10 +243,11 @@ export const Plugin = { ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}), }, ) - return { + const output = { provider, text: text ?? NO_RESULTS, } + return { output, content: output.text, metadata: { provider: output.provider } } }).pipe( Effect.mapError( (error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }), diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 6c8041ff424..4d86df9729c 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -53,13 +53,11 @@ export const Plugin = { .transform((draft) => draft.add( name, - Tool.withPermission( - Tool.make({ + Tool.make({ description: "Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => Effect.gen(function* () { const source = { @@ -86,12 +84,11 @@ export const Plugin = { }) return yield* files.writeTextPreservingBom({ target, content: input.content }) }).pipe( + Effect.map((output) => ({ output, content: toModelOutput(output) })), Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })), ), }), - "edit", - ), - { codemode: false }, + { codemode: false, permission: "edit" }, ), ) .pipe(Effect.orDie) diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts index 695be92145e..b80eb1a9855 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -9,14 +9,16 @@ describe("CodeMode", () => { it.effect("owns registrations, execute, and catalog materialization", () => Effect.gen(function* () { const codeMode = yield* CodeMode.Service - yield* codeMode.register({ - echo: Tool.make({ - description: "Echo text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.String, - execute: ({ text }) => Effect.succeed(text), + yield* codeMode.register( + Tool.registrationEntries({ + echo: Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.String, + execute: ({ text }) => Effect.succeed({ output: text }), + }), }), - }) + ) const materialized = yield* codeMode.materialize() expect(materialized.tool).toBeDefined() diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 58673f20393..f98ded68f3a 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -25,6 +25,7 @@ import addSessionForkMigration from "@opencode-ai/core/database/migration/202607 import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended" import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync" import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events" +import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -583,6 +584,208 @@ describe("DatabaseMigration", () => { ) }) + test("rewrites projected tool rows into the canonical result shape", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run( + sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, type text NOT NULL, data text NOT NULL)`) + const assistant = { + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { type: "text", text: "before" }, + { + type: "tool", + id: "call_content", + name: "grep", + state: { + status: "completed", + input: { pattern: "TODO" }, + content: [{ type: "text", text: "src/a.ts:1: TODO" }], + structured: { value: [{ file: "src/a.ts", line: 1 }] }, + }, + time: { created: 1, completed: 2 }, + }, + { + type: "tool", + id: "call_structured_only", + name: "read", + state: { + status: "completed", + input: { path: "README.md" }, + content: [], + structured: { text: "hello" }, + }, + time: { created: 1, completed: 2 }, + }, + { + type: "tool", + id: "call_hosted", + name: "web_search", + executed: true, + providerResultState: { blockType: "web_search_tool_result" }, + state: { + status: "completed", + input: { query: "effect" }, + content: [], + structured: {}, + result: { type: "json", value: [{ url: "https://example.com" }] }, + }, + time: { created: 1, completed: 2 }, + }, + { + type: "tool", + id: "call_failed", + name: "shell", + state: { + status: "error", + input: { command: "sleep 99" }, + error: { type: "tool.execution", message: "timed out" }, + content: [{ type: "text", text: "partial output" }], + structured: { truncated: false }, + result: { type: "error", value: "timed out" }, + }, + time: { created: 1, completed: 2 }, + }, + { + type: "tool", + id: "call_running", + name: "shell", + state: { + status: "running", + input: { command: "sleep 1" }, + structured: { truncated: false }, + content: [{ type: "text", text: "tick" }], + }, + time: { created: 1, ran: 2 }, + }, + ], + time: { created: 1 }, + } + yield* db.run( + sql`INSERT INTO session_message VALUES ('msg_tools', 'ses_test', 'assistant', 1, 10, 11, ${JSON.stringify(assistant)})`, + ) + yield* db.run( + sql`INSERT INTO session_message VALUES ('msg_user', 'ses_test', 'user', 2, 12, 13, '{"text":"hi","time":{"created":1}}')`, + ) + // A row that never decoded must be skipped, not fail the migration. + yield* db.run( + sql`INSERT INTO session_message VALUES ('msg_corrupt', 'ses_test', 'assistant', 3, 14, 15, 'not json')`, + ) + yield* db.run( + sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({ + sessionID: "ses_test", + assistantMessageID: "msg_tools", + callID: "call_hosted", + structured: {}, + content: [], + result: { type: "json", value: [{ url: "https://example.com" }] }, + executed: true, + })})`, + ) + yield* db.run( + sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({ + sessionID: "ses_test", + assistantMessageID: "msg_tools", + callID: "call_failed", + error: { type: "tool.execution", message: "timed out" }, + metadata: { truncated: false }, + executed: false, + })})`, + ) + + yield* DatabaseMigration.applyOnly(db, [canonicalToolResultsMigration]) + + const row = yield* db.get<{ data: string }>(sql`SELECT data FROM session_message WHERE id = 'msg_tools'`) + const migrated = JSON.parse(row!.data) + // Every migrated row must decode with the current schema; reload hard-fails otherwise. + Schema.decodeUnknownSync(SessionMessage.Info)({ ...migrated, id: "msg_tools", type: "assistant" }) + const states = new Map( + migrated.content.flatMap((part: { type: string; id?: string }) => + part.type === "tool" ? [[part.id, part]] : [], + ), + ) + expect(states.get("call_content")).toMatchObject({ + state: { + status: "completed", + input: { pattern: "TODO" }, + content: [{ type: "text", text: "src/a.ts:1: TODO" }], + // Old generic structured payloads survive as canonical metadata. + metadata: { value: [{ file: "src/a.ts", line: 1 }] }, + }, + }) + expect((states.get("call_content") as { state: Record }).state).not.toHaveProperty( + "structured", + ) + expect(states.get("call_structured_only")).toMatchObject({ + state: { + status: "completed", + content: [{ type: "text", text: JSON.stringify({ text: "hello" }, null, 2) }], + metadata: { text: "hello" }, + }, + }) + expect(states.get("call_hosted")).toMatchObject({ + executed: true, + providerResultState: { + blockType: "web_search_tool_result", + result: [{ url: "https://example.com" }], + }, + state: { + status: "completed", + content: [{ type: "text", text: JSON.stringify([{ url: "https://example.com" }], null, 2) }], + }, + }) + expect(states.get("call_failed")).toMatchObject({ + state: { + status: "error", + error: { type: "tool.execution", message: "timed out" }, + content: [{ type: "text", text: "partial output" }], + metadata: { truncated: false }, + }, + }) + const failedState = (states.get("call_failed") as { state: Record }).state + expect(failedState).not.toHaveProperty("result") + expect(failedState).not.toHaveProperty("structured") + expect(states.get("call_running")).toMatchObject({ + state: { + status: "running", + metadata: { truncated: false }, + }, + }) + const event = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_success'`) + expect(event!.type).toBe("session.tool.success.1") + expect(JSON.parse(event!.data)).toEqual({ + sessionID: "ses_test", + assistantMessageID: "msg_tools", + callID: "call_hosted", + structured: {}, + content: [], + result: { type: "json", value: [{ url: "https://example.com" }] }, + executed: true, + }) + const failedEvent = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_failed'`) + expect(failedEvent!.type).toBe("session.tool.failed.1") + expect(JSON.parse(failedEvent!.data)).toEqual({ + sessionID: "ses_test", + assistantMessageID: "msg_tools", + callID: "call_failed", + error: { type: "tool.execution", message: "timed out" }, + metadata: { truncated: false }, + executed: false, + }) + expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_user'`)).toEqual({ + data: '{"text":"hi","time":{"created":1}}', + }) + expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_corrupt'`)).toEqual({ + data: "not json", + }) + }), + ) + }) + test("records the authoritative parent sequence on existing forks", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index e3fdccb13ae..2f41c3b9b47 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -14,7 +14,7 @@ export const toolIdentity = { } export const toolDefinitions = (registry: ToolRegistry.Interface, permissions?: PermissionV2.Ruleset) => - registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions)) + registry.snapshot(permissions).pipe(Effect.map((toolSet) => toolSet.definitions)) export function waitForTool( registry: ToolRegistry.Interface, @@ -35,7 +35,7 @@ export function waitForTool( /** * Registers a core tool plugin's tools against the real registry without booting the * full plugin host. Only the tool domain is live; focused tool tests exercise - * registration, materialization, and settlement through the same path production uses. + * registration, snapshots, and execution through the same path production uses. */ export const registerToolPlugin = (plugin: { readonly id: string @@ -52,7 +52,7 @@ export const registerToolPlugin = (plugin: { Effect.gen(function* () { const registrations: Array<{ readonly name: string - readonly tool: Tool.AnyTool + readonly tool: Tool.Any readonly options?: Tool.RegisterOptions }> = [] callback({ @@ -73,8 +73,5 @@ export const registerToolPlugin = (plugin: { yield* plugin.effect(context) }) -export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) => - registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input))) - export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) => - settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result)) + registry.snapshot().pipe(Effect.flatMap((toolSet) => toolSet.execute(input))) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 158ad32c674..55933feb1f9 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -33,7 +33,7 @@ import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" import { location } from "./fixture/location" -import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" +import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" let assertion: Deferred.Deferred | undefined let decision: Effect.Effect = Effect.void @@ -241,10 +241,41 @@ const mcp = Layer.mock(MCP.Service, { description: "Lookup", inputSchema: { type: "object", properties: {} }, }), + new MCP.Tool({ + server: MCP.ServerName.make("direct"), + name: "fail", + codemode: false, + description: "Always fails", + inputSchema: { type: "object", properties: {} }, + }), + new MCP.Tool({ + server: MCP.ServerName.make("direct"), + name: "media", + codemode: false, + description: "Returns text and an image", + inputSchema: { type: "object", properties: {} }, + }), ]), callTool: (input) => Effect.sync(() => { calls += 1 + if (input.name === "fail") + return new MCP.ToolResult({ + server: MCP.ServerName.make(input.server), + tool: input.name, + isError: true, + content: [{ type: "text", text: "search index unavailable" }], + }) + if (input.name === "media") + return new MCP.ToolResult({ + server: MCP.ServerName.make(input.server), + tool: input.name, + isError: false, + content: [ + { type: "text", text: "rendered chart" }, + { type: "media", data: "aGVsbG8=", mimeType: "image/png" }, + ], + }) return new MCP.ToolResult({ server: MCP.ServerName.make(input.server), tool: input.name, @@ -647,9 +678,7 @@ test("loads and reads MCP resources", async () => { }) expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" }) }).pipe( - Effect.provide( - resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } }), - ), + Effect.provide(resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } })), ) }), ), @@ -774,8 +803,8 @@ it.effect("advertises MCP output schemas to Code Mode", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service yield* waitForTool(registry, "execute") - const materialized = yield* registry.materialize() - const execute = materialized.definitions.find((tool) => tool.name === "execute") + const definitions = yield* toolDefinitions(registry) + const execute = definitions.find((tool) => tool.name === "execute") expect(execute?.description).not.toContain("tools.demo.search") }), @@ -793,6 +822,50 @@ it.effect("advertises MCP tools directly when Code Mode is disabled for the serv }), ) +// Baseline (PLAN.md step 1): MCP isError must become one failed tool call, not a +// success whose text happens to describe an error. +it.effect("fails the call when MCP reports isError", () => + Effect.gen(function* () { + assertion = yield* Deferred.make() + decision = Effect.void + const registry = yield* ToolRegistry.Service + yield* waitForTool(registry, "direct_fail") + + const execution = yield* executeTool(registry, { + sessionID: SessionV2.ID.make("ses_mcp_is_error"), + ...toolIdentity, + call: { type: "tool-call", id: "call_mcp_is_error", name: "direct_fail", input: {} }, + }) + + expect(execution).toMatchObject({ status: "error", error: { message: "search index unavailable" } }) + expect(execution.content).toBeUndefined() + }), +) + +// Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact. +it.effect("preserves MCP text and media content for the model", () => + Effect.gen(function* () { + assertion = yield* Deferred.make() + decision = Effect.void + const registry = yield* ToolRegistry.Service + yield* waitForTool(registry, "direct_media") + + const execution = yield* executeTool(registry, { + sessionID: SessionV2.ID.make("ses_mcp_media"), + ...toolIdentity, + call: { type: "tool-call", id: "call_mcp_media", name: "direct_media", input: {} }, + }) + + expect(execution.status).toBe("completed") + if (execution.status !== "completed") return + expect(execution.output).toBe("rendered chart") + expect(execution.content).toMatchObject([ + { type: "text", text: "rendered chart" }, + { type: "file", mime: "image/png" }, + ]) + }), +) + it.effect("waits for permission before calling an MCP tool", () => Effect.gen(function* () { calls = 0 @@ -802,7 +875,7 @@ it.effect("waits for permission before calling an MCP tool", () => const registry = yield* ToolRegistry.Service yield* waitForTool(registry, "execute") - const fiber = yield* settleTool(registry, { + const fiber = yield* executeTool(registry, { sessionID: SessionV2.ID.make("ses_mcp_permission"), ...toolIdentity, call: { @@ -841,7 +914,7 @@ it.effect("does not call MCP when permission is blocked", () => const registry = yield* ToolRegistry.Service yield* waitForTool(registry, "execute") - const settlement = yield* settleTool(registry, { + const execution = yield* executeTool(registry, { sessionID: SessionV2.ID.make("ses_mcp_blocked"), ...toolIdentity, call: { @@ -851,8 +924,9 @@ it.effect("does not call MCP when permission is blocked", () => input: { code: "return await tools.demo.search({})" }, }, }) - expect(settlement.result).toEqual({ type: "text", value: "Unable to execute demo_search" }) - expect(settlement.output?.structured).toEqual({ + expect(execution.status).toBe("completed") + expect(execution.content).toEqual([{ type: "text", text: "Unable to execute demo_search" }]) + expect(execution.metadata).toEqual({ toolCalls: [{ tool: "demo.search", status: "error" }], error: true, }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 0d19e4e74b9..b7840581498 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -258,7 +258,7 @@ describe("PluginV2", () => { description: "Plugin tool", input: Schema.Struct({}), output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), + execute: () => Effect.succeed({ output: { ok: true } }), }), { codemode: false }, ), @@ -267,10 +267,10 @@ describe("PluginV2", () => { }) yield* plugins.activate([versioned(plugin)]) - expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain("plugin_tool") + expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain("plugin_tool") yield* plugins.activate([]) - expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool") + expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).not.toContain("plugin_tool") }), ) @@ -283,7 +283,7 @@ describe("PluginV2", () => { description, input: Schema.Struct({}), output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), + execute: () => Effect.succeed({ output: { ok: true } }), }) const plugin = EffectPlugin.define({ id: "grouped-tools", @@ -299,7 +299,7 @@ describe("PluginV2", () => { yield* plugins.activate([versioned(plugin)]) - expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toEqual([ + expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toEqual([ "plain", "context7_look_up", "execute", @@ -307,14 +307,14 @@ describe("PluginV2", () => { }), ) - it.effect("fires before/after tool hooks with mutable events around settlement", () => + it.effect("fires before/after tool hooks with mutable events around execution", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const registry = yield* ToolRegistry.Service const executed: unknown[] = [] const seen: { before?: unknown - after?: { input: unknown; result: unknown; output: unknown } + after?: { input: unknown; status: string; content: unknown; metadata: unknown } } = {} const plugin = EffectPlugin.define({ @@ -329,7 +329,8 @@ describe("PluginV2", () => { description: "Echo", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })), + execute: ({ text }) => + Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })), }), { codemode: false }, ), @@ -348,9 +349,23 @@ describe("PluginV2", () => { yield* ctx.tool .hook("execute.after", (event) => Effect.sync(() => { - seen.after = { input: event.input, result: event.result, output: event.output } - event.result = { type: "text", value: "after-mutated" } - event.output = { structured: { rewritten: true }, content: [] } + seen.after = { + input: event.input, + status: event.status, + content: event.content, + metadata: event.metadata, + } + if (event.status !== "completed") return + event.content = [{ type: "text", text: "after-mutated" }] + event.metadata = { rewritten: true } + }), + ) + .pipe(Effect.asVoid) + + yield* ctx.tool + .hook("execute.after", (event) => + Effect.sync(() => { + if (event.status === "completed") event.content = [] as never }), ) .pipe(Effect.asVoid) @@ -359,8 +374,8 @@ describe("PluginV2", () => { yield* plugins.activate([versioned(plugin)]) - const materialized = yield* registry.materialize() - const settlement = yield* materialized.settle({ + const toolSet = yield* registry.snapshot() + const execution = yield* toolSet.execute({ sessionID: SessionV2.ID.make("ses_hooks"), agent: AgentV2.ID.make("build"), messageID: SessionMessage.ID.make("msg_hooks"), @@ -371,11 +386,15 @@ describe("PluginV2", () => { expect(executed).toEqual([{ text: "before-mutated" }]) expect(seen.after).toEqual({ input: { text: "before-mutated" }, - result: { type: "json", value: { text: "before-mutated" } }, - output: { structured: { text: "before-mutated" }, content: [] }, + status: "completed", + content: [{ type: "text", text: '{"text":"before-mutated"}' }], + metadata: undefined, + }) + expect(execution).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "after-mutated" }], + metadata: { rewritten: true }, }) - expect(settlement.result).toEqual({ type: "text", value: "after-mutated" }) - expect(settlement.output).toEqual({ structured: { rewritten: true }, content: [] }) }), ) }) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 7922d965bba..76e2a1e31b1 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -14,6 +14,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ProviderV2 } from "@opencode-ai/core/provider" import { Plugin } from "@opencode-ai/plugin/v2" +import { Tool } from "@opencode-ai/plugin/v2/tool" import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" import { Model } from "@opencode-ai/schema/model" import { Provider } from "@opencode-ai/schema/provider" @@ -270,7 +271,7 @@ describe("fromPromise", () => { }), ) - it.effect("constructs plain Promise tool declarations in the host", () => + it.effect("constructs plain Promise tool definitions in the host", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const registry = yield* ToolRegistry.Service @@ -280,35 +281,41 @@ describe("fromPromise", () => { id: "promise-tool", setup: async (ctx) => { await ctx.tool.transform((tools) => { - tools.add({ - name: "hello", - options: { codemode: false }, - description: "Hello", - input: Schema.Struct({ name: Schema.String }), - output: Schema.String, - execute: async ({ name }, context) => { - await context.progress({ structured: { phase: "greeting" } }) - return `Hello, ${name}!` - }, - }) + tools.add( + "hello", + Tool.make({ + description: "Hello", + input: Schema.Struct({ name: Schema.String }), + output: Schema.String, + execute: async ({ name }, context) => { + await context.progress({ phase: "greeting" }) + return { output: `Hello, ${name}!` } + }, + }), + { codemode: false }, + ) }) }, }) yield* PluginPromise.fromPromise(promisePlugin).effect(host) - const materialized = yield* registry.materialize() - expect(materialized.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" })) + const toolSet = yield* registry.snapshot() + expect(toolSet.definitions).toContainEqual(expect.objectContaining({ name: "hello", description: "Hello" })) expect( - yield* materialized.settle({ + yield* toolSet.execute({ sessionID: SessionV2.ID.make("ses_promise_tool"), agent: AgentV2.ID.make("build"), messageID: SessionMessage.ID.make("msg_promise_tool"), progress: (update) => Effect.sync(() => progress.push(update)), call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } }, }), - ).toMatchObject({ result: { type: "text", value: "Hello, world!" } }) - expect(progress).toEqual([{ structured: { phase: "greeting" }, content: [] }]) + ).toMatchObject({ + status: "completed", + output: "Hello, world!", + content: [{ type: "text", text: "Hello, world!" }], + }) + expect(progress).toEqual([{ phase: "greeting" }]) }), ) }) diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index e78f41bdac0..7471564610b 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -95,10 +95,10 @@ const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effec const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) }) const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void }) const tools = Layer.mock(ToolRegistry.Service, { - materialize: () => + snapshot: () => Effect.succeed({ definitions: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })], - settle: () => Effect.die(new Error("unused")), + execute: () => Effect.die(new Error("unused")), }), register: () => Effect.die(new Error("unused")), registerBatch: () => Effect.die(new Error("unused")), diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index f0720cfc12f..9d40f95f4b5 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -34,7 +34,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { tempLocationLayer } from "./fixture/location" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" -import { registerToolPlugin, settleTool } from "./lib/tool" +import { executeTool, registerToolPlugin } from "./lib/tool" const readToolNode = makeLocationNode({ name: "test/read-tool-plugin", @@ -163,7 +163,7 @@ describe("SessionInstructions", () => { // A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but // excluding the Location root (already supplied by core initial instructions). - yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt")) + yield* executeTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt")) const firstInjected = yield* synthetics(sessionID) expect(firstInjected).toHaveLength(1) @@ -179,7 +179,7 @@ describe("SessionInstructions", () => { // A sibling read under sub/other discovers only the new AGENTS.md; sub is already // injected for this session so it is not re-emitted, and the root is still excluded. - yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt")) + yield* executeTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt")) const secondInjected = yield* synthetics(sessionID) expect(secondInjected).toHaveLength(2) @@ -210,7 +210,7 @@ describe("SessionInstructions", () => { yield* seedSynthetic(sessionID, [subPath]) expect(yield* synthetics(sessionID)).toHaveLength(1) - yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt")) + yield* executeTool(registry, readCall(sessionID, "call-sub", "sub/file.txt")) // The durable claim on the prior synthetic prevents re-injection; no new synthetic. expect(yield* synthetics(sessionID)).toHaveLength(1) @@ -236,7 +236,7 @@ describe("SessionInstructions", () => { // Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding // the Location root (already supplied by core initial instructions). - yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo")) + yield* executeTool(registry, readCall(sessionID, "call-list", "packages/foo")) const firstInjected = yield* synthetics(sessionID) expect(firstInjected).toHaveLength(1) @@ -247,7 +247,7 @@ describe("SessionInstructions", () => { // A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is // already injected for this session, so nothing new is emitted. - yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt")) + yield* executeTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt")) expect(yield* synthetics(sessionID)).toHaveLength(1) }), @@ -269,7 +269,7 @@ describe("SessionInstructions", () => { // The walk starts and stops at the Location root: the root AGENTS.md is searched but // dropped by the dirname filter, and up() only walks upward so nested dirs are unseen. - yield* settleTool(registry, readCall(sessionID, "call-root-list", ".")) + yield* executeTool(registry, readCall(sessionID, "call-root-list", ".")) expect(yield* synthetics(sessionID)).toHaveLength(0) }), diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index a79b351cfa3..3cebdcb985d 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -367,8 +367,7 @@ Recent work state: SessionMessage.ToolStateRunning.make({ status: "running", input: { path: "README.md" }, - content: [], - structured: { type: "media", mime: "image/png" }, + metadata: { type: "media", mime: "image/png" }, }), time: { created }, }), @@ -388,7 +387,6 @@ Recent work name: "hello.png", }, ], - structured: {}, }), time: { created, completed: created }, }), @@ -403,7 +401,6 @@ Recent work status: "completed", input: { query: "Effect" }, content: [{ type: "text", text: "Found it" }], - structured: {}, }), time: { created, completed: created }, }), @@ -416,8 +413,6 @@ Recent work state: SessionMessage.ToolStateError.make({ status: "error", input: { path: "README.md" }, - content: [], - structured: {}, error: { type: "unknown", message: "Denied" }, }), time: { created, completed: created }, @@ -473,7 +468,7 @@ Recent work providerMetadata: { provider: { continuation: "failed" } }, result: { type: "error", - value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} }, + value: { error: { type: "unknown", message: "Denied" }, content: [] }, }, }, ]) @@ -575,9 +570,7 @@ Recent work state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, - content: [], - structured: {}, - result: { type: "json", value: { found: true } }, + content: [{ type: "text", text: '{"found":true}' }], }), time: { created, completed: created }, }), @@ -592,8 +585,6 @@ Recent work status: "error", input: { query: "Effect" }, error: { type: "unknown", message: "Step interrupted" }, - content: [], - structured: {}, }), time: { created, completed: created }, }), @@ -620,8 +611,10 @@ Recent work type: "tool-result", id: "hosted-completed", name: "web_search", - result: { type: "json", value: { found: true } }, + result: { type: "text", value: '{"found":true}' }, providerExecuted: true, + cache: undefined, + metadata: undefined, providerMetadata: { provider: { itemId: "result_completed" } }, }, { @@ -630,7 +623,7 @@ Recent work name: "web_search", input: { query: "Effect" }, providerExecuted: true, - providerMetadata: undefined, + providerMetadata: { provider: { itemId: "call_failed" } }, }, { type: "tool-result", @@ -641,18 +634,17 @@ Recent work value: { error: { type: "unknown", message: "Step interrupted" }, content: [], - structured: {}, }, }, providerExecuted: true, cache: undefined, metadata: undefined, - providerMetadata: undefined, + providerMetadata: { provider: { itemId: "result_failed" } }, }, ]) }) - test("drops provider-native continuation metadata after a model switch", () => { + test("drops model-scoped continuation metadata after a model switch but keeps hosted result payloads", () => { const messages = toLLMMessages( [ SessionMessage.Assistant.make({ @@ -676,9 +668,7 @@ Recent work state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { query: "Effect" }, - content: [], - structured: {}, - result: { type: "json", value: { status: "completed" } }, + content: [{ type: "text", text: '{"status":"completed"}' }], }), time: { created, completed: created }, }), @@ -692,8 +682,7 @@ Recent work state: SessionMessage.ToolStateCompleted.make({ status: "completed", input: { path: "README.md" }, - content: [], - structured: { text: "Hello" }, + content: [{ type: "text", text: "Hello" }], }), time: { created, completed: created }, }), @@ -718,11 +707,13 @@ Recent work type: "tool-result", id: "hosted-old-model", name: "web_search", - result: { type: "json", value: { status: "completed" } }, + result: { type: "text", value: '{"status":"completed"}' }, providerExecuted: true, cache: undefined, metadata: undefined, - providerMetadata: undefined, + // Hosted result payloads are provider-format state and must survive a + // model switch within the same provider for replay to stay valid. + providerMetadata: { provider: { itemId: "hosted-old-model" } }, }, { type: "tool-call", @@ -738,7 +729,7 @@ Recent work type: "tool-result", id: "local-old-model", name: "read", - result: { type: "json", value: { text: "Hello" } }, + result: { type: "text", value: "Hello" }, providerExecuted: false, cache: undefined, metadata: undefined, diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 66a0a43f545..2ba096b306a 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -50,7 +50,7 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru } const call = LLMEvent.toolCall({ id: "call-image", name: "read", input: { path: "pixel.png" } }) -const result = LLMEvent.toolResult({ +const hostedResult = LLMEvent.toolResult({ id: "call-image", name: "read", result: { @@ -60,25 +60,28 @@ const result = LLMEvent.toolResult({ { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" }, ], }, - output: { - structured: { type: "media", mime: "image/png" }, - content: [ - { type: "text", text: "Image read successfully" }, - { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" }, - ], - }, }) -test("local tool success serializes media base64 once and reconstructs from structured content", async () => { +test("local tool success serializes media base64 once through canonical content", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(call)) - await Effect.runPromise(publisher.publish(result)) + await Effect.runPromise( + publisher.toolExecution(call.id, call.name, { + status: "completed", + output: { type: "media", mime: "image/png" }, + content: [ + { type: "text", text: "Image read successfully" }, + { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png", name: "pixel.png" }, + ], + }), + ) - const success = published.find((event) => event.type === "session.tool.success.1") + const success = published.find((event) => event.type === "session.tool.success.2") expect(success).toBeDefined() const serialized = JSON.stringify(success) expect(serialized.split(base64)).toHaveLength(2) expect(success?.data).not.toHaveProperty("result") + expect(success?.data).not.toHaveProperty("output") expect(success?.data).toMatchObject({ content: [ @@ -88,29 +91,41 @@ test("local tool success serializes media base64 once and reconstructs from stru }) }) -test("provider-executed success retains its raw provider result", async () => { +test("provider-executed success derives content and retains provider result state", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true }))) - await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true }))) - const success = published.find((event) => event.type === "session.tool.success.1") - expect(success?.data).toHaveProperty("result") + await Effect.runPromise( + publisher.publish( + LLMEvent.toolResult({ + ...hostedResult, + providerExecuted: true, + providerMetadata: { anthropic: { result: { type: "content", value: [] } } }, + }), + ), + ) + const success = published.find((event) => event.type === "session.tool.success.2") + expect(success?.data).not.toHaveProperty("result") + expect(success?.data).toMatchObject({ + executed: true, + content: [ + { type: "text", text: "Image read successfully" }, + { type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }, + ], + resultState: { result: { type: "content" } }, + }) }) -test("interrupted progress publication remains in the terminal failure snapshot", async () => { +test("interrupted progress metadata remains in the terminal failure snapshot", async () => { const { published, publisher } = capture("anthropic", { interruptProgress: true }) await Effect.runPromise(publisher.publish(call)) const exit = await Effect.runPromiseExit( - publisher.progress(call.id, { - structured: { phase: "visible" }, - content: [{ type: "text", text: "visible" }], - }), + publisher.progress(call.id, { phase: "visible" }), ) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" })) - expect(published.find((event) => event.type === "session.tool.failed.1")?.data).toMatchObject({ + expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({ metadata: { phase: "visible" }, - content: [{ type: "text", text: "visible" }], }) }) @@ -119,7 +134,7 @@ test("failure before progress omits partial output fields", async () => { await Effect.runPromise(publisher.publish(call)) await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" })) - const failed = published.find((event) => event.type === "session.tool.failed.1")?.data + const failed = published.find((event) => event.type === "session.tool.failed.2")?.data expect(failed).not.toHaveProperty("content") expect(failed).not.toHaveProperty("metadata") }) @@ -192,7 +207,7 @@ test("provider-executed tool metadata is flattened using the route key", async ( expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({ state: { itemId: "call" }, }) - expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({ + expect(published.find((event) => event.type === "session.tool.success.2")?.data).toMatchObject({ resultState: { itemId: "result" }, }) }) @@ -201,29 +216,30 @@ test("binary failure emits no success event", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(call)) await Effect.runPromise( - publisher.publish( - LLMEvent.toolResult({ - id: call.id, - name: call.name, - result: { type: "error", value: "Cannot read binary file" }, - }), - ), + publisher.toolExecution(call.id, call.name, { + status: "error", + error: { type: "tool.execution", message: "Cannot read binary file" }, + }), ) - expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false) - expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true) + expect(published.some((event) => event.type === "session.tool.success.2")).toBe(false) + expect(published.some((event) => event.type === "session.tool.failed.2")).toBe(true) }) -test("success event data can carry a provider-executed result", () => { +test("success event data can carry provider-executed result state", () => { const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({ sessionID, assistantMessageID: SessionMessage.ID.create(), callID: "call-old", - structured: { type: "media", mime: "image/png" }, content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }], - result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] }, executed: true, + resultState: { + result: { + type: "content", + value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }], + }, + }, }) - expect(decoded.result).toMatchObject({ type: "content" }) + expect(decoded.resultState).toMatchObject({ result: { type: "content" } }) }) test("step finish records settlement without publishing step ended", async () => { diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 053b2152698..29e1e17c145 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -8,23 +8,24 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolRegistry } from "@opencode-ai/core/tool/registry" -import { executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { executeTool, toolDefinitions } from "./lib/tool" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" import { testEffect } from "./lib/effect" const bounds: ToolOutputStore.BoundInput[] = [] const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") }) const outputStore = Layer.mock(ToolOutputStore.Service, { + limits: () => Effect.succeed({ maxLines: ToolOutputStore.MAX_LINES, maxBytes: ToolOutputStore.MAX_BYTES }), bound: (input) => { if (input.callID === "call-retention-failure") return Effect.fail(retentionFailure) return Effect.sync(() => bounds.push(input)).pipe( Effect.as( input.callID === "call-bounded" ? { - output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] }, + content: [{ type: "text" as const, text: "bounded reference" }], outputPaths: ["/managed/generic"], } - : { output: input.output, outputPaths: [] }, + : { content: input.content, outputPaths: [] }, ), ) }, @@ -63,24 +64,20 @@ const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ( call: { type: "tool-call", id, name, input: { text: name } }, }) -const make = (permission?: string) => { - const tool = Tool.make({ +const make = () => + Tool.make({ description: "Echo text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.succeed({ text }), - toModelOutput: ({ output }) => [{ type: "text", text: output.text }], + execute: ({ text }) => Effect.succeed({ output: { text }, content: text }), }) - return permission ? Tool.withPermission(tool, permission) : tool -} const constant = (text: string) => Tool.make({ description: "Return text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: () => Effect.succeed({ text }), - toModelOutput: ({ output }) => [{ type: "text" as const, text: output.text }], + execute: () => Effect.succeed({ output: { text }, content: text }), }) describe("ToolRegistry", () => { @@ -91,7 +88,21 @@ describe("ToolRegistry", () => { expect(error).toBeInstanceOf(Tool.RegistrationError) expect(error.message).toBe('Invalid tool namespace: "slack..admin"') - expect((yield* service.materialize()).definitions).toEqual([]) + expect((yield* service.snapshot()).definitions).toEqual([]) + }), + ) + + it.effect("rejects invalid and colliding normalized names", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const invalid = yield* service.register({ "123": make() }, { codemode: false }).pipe(Effect.flip) + expect(invalid.message).toBe("Invalid tool name: 123") + + const collision = yield* service + .register({ "echo.tool": make(), echo_tool: make() }, { codemode: false }) + .pipe(Effect.flip) + expect(collision.message).toBe("Duplicate normalized tool name: echo_tool") + expect((yield* service.snapshot()).definitions).toEqual([]) }), ) @@ -106,19 +117,15 @@ describe("ToolRegistry", () => { .pipe(Effect.flip) expect(error).toBeInstanceOf(Tool.RegistrationError) - expect((yield* service.materialize()).definitions).toEqual([]) + expect((yield* service.snapshot()).definitions).toEqual([]) }), ) it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service - yield* service.register({ - question: make(), - bash: make(), - edit: make("edit"), - write: make("edit"), - }, { codemode: false }) + yield* service.register({ question: make(), bash: make() }, { codemode: false }) + yield* service.register({ edit: make(), write: make() }, { codemode: false, permission: "edit" }) const names = (permissions: PermissionV2.Ruleset) => toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name))) @@ -139,18 +146,15 @@ describe("ToolRegistry", () => { }), ) - it.effect("keeps permission decoration isolated between registrations", () => + it.effect("keeps permission options isolated between registrations", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service const shared = make() yield* service.register({ first: shared }, { codemode: false }) - yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false }) - Tool.withPermission(shared, "question") + yield* service.register({ second: shared }, { codemode: false, permission: "edit" }) expect( - (yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map( - (definition) => definition.name, - ), + (yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map((tool) => tool.name), ).toEqual(["first"]) }), ) @@ -191,41 +195,47 @@ describe("ToolRegistry", () => { it.effect("returns model errors without swallowing interruption or defects", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service - yield* service.register({ - failed: Tool.make({ - description: "Failed", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })), - }), - }, { codemode: false }) + yield* service.register( + { + failed: Tool.make({ + description: "Failed", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })), + }), + }, + { codemode: false }, + ) expect( yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "failed", name: "failed", input: {} }, }), - ).toEqual({ type: "error", value: "Denied" }) + ).toEqual({ status: "error", error: { type: "tool.execution", message: "Denied" } }) expect( yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "missing", name: "missing", input: {} }, }), - ).toEqual({ type: "error", value: "Unknown tool: missing" }) + ).toEqual({ status: "error", error: { type: "tool.unknown", message: "Unknown tool: missing" } }) - yield* service.register({ - defect: Tool.make({ - description: "Defect", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => Effect.die("unexpected executor defect"), - }), - }, { codemode: false }) + yield* service.register( + { + defect: Tool.make({ + description: "Defect", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => Effect.die("unexpected executor defect"), + }), + }, + { codemode: false }, + ) expect( - yield* service.materialize().pipe( - Effect.flatMap((materialized) => - materialized.settle({ + yield* service.snapshot().pipe( + Effect.flatMap((toolSet) => + toolSet.execute({ sessionID, ...identity, call: { type: "tool-call", id: "defect", name: "defect", input: {} }, @@ -237,12 +247,12 @@ describe("ToolRegistry", () => { }), ) - it.effect("propagates retention failures through settlement", () => + it.effect("propagates retention failures through execution", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service yield* service.register({ echo: make() }, { codemode: false }) - const materialized = yield* service.materialize() - const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit) + const toolSet = yield* service.snapshot() + const exit = yield* toolSet.execute(call("echo", "call-retention-failure")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure) @@ -250,79 +260,88 @@ describe("ToolRegistry", () => { }), ) - it.effect("exposes settlement only through materialization", () => + it.effect("exposes execution only through a snapshot", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service expect("definitions" in service).toBe(false) expect("execute" in service).toBe(false) expect("settle" in service).toBe(false) - expect(typeof service.materialize).toBe("function") + expect(typeof service.snapshot).toBe("function") }), ) - it.effect("passes complete invocation identity to the canonical handler", () => + it.effect("passes complete call identity to tool execution", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service const contexts: Tool.Context[] = [] - yield* service.register({ - context: Tool.make({ - description: "Context", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })), - }), - }, { codemode: false }) + yield* service.register( + { + context: Tool.make({ + description: "Context", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: (_, context) => + Effect.sync(() => contexts.push(context)).pipe(Effect.as({ output: { ok: true } })), + }), + }, + { codemode: false }, + ) yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "call-context", name: "context", input: {} }, }) - expect(contexts).toEqual([ - { sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }, - ]) + expect(contexts).toEqual([{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }]) }), ) - it.effect("encodes output and applies generic settlement bounding", () => + it.effect("encodes output and applies generic execution bounding", () => Effect.gen(function* () { bounds.length = 0 const service = yield* ToolRegistry.Service yield* service.register({ bounded: make() }, { codemode: false }) expect( - yield* settleTool(service, { + yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } }, }), ).toEqual({ - result: { type: "text", value: "bounded reference" }, - output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] }, + status: "completed", + output: { text: "complete" }, + content: [{ type: "text", text: "bounded reference" }], outputPaths: ["/managed/generic"], }) expect(bounds).toHaveLength(1) }), ) - it.effect("normalizes image tool output at settlement and drops unresizable images", () => + it.effect("normalizes image tool output at execution and drops unresizable images", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service - yield* service.register({ - snapshot: Tool.make({ - description: "Return images", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.succeed({ text }), - toModelOutput: ({ output }) => [ - { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" }, - { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" }, - { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" }, - { type: "text", text: output.text }, - ], - }), - }, { codemode: false }) + yield* service.register( + { + snapshot: Tool.make({ + description: "Return images", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => + Effect.succeed({ + output: { text }, + content: [ + { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" }, + { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" }, + { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" }, + { type: "text", text }, + ], + }), + }), + }, + { codemode: false }, + ) - const settlement = yield* settleTool(service, call("snapshot")) - expect(settlement.output?.content).toEqual([ + const execution = yield* executeTool(service, call("snapshot")) + expect(execution.content).toEqual([ { type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" }, { type: "text", text: "snapshot" }, { type: "text", text: "[1 image omitted: could not be decoded.]" }, @@ -331,44 +350,31 @@ describe("ToolRegistry", () => { }), ) - it.effect("normalizes image progress content before it is published", () => + it.effect("publishes progress metadata unchanged", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service - yield* service.register({ - progressive: Tool.make({ - description: "Emit image progress", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: ({ text }, context) => - context - .progress({ - structured: { stage: "capture" }, - content: [ - { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" }, - { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" }, - ], - }) - .pipe(Effect.as({ text })), - }), - }, { codemode: false }) + yield* service.register( + { + progressive: Tool.make({ + description: "Emit image progress", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }, context) => + context.progress({ stage: "capture" }).pipe(Effect.as({ output: { text } })), + }), + }, + { codemode: false }, + ) const updates: ToolRegistry.Progress[] = [] - yield* settleTool(service, { + yield* executeTool(service, { ...call("progressive"), progress: (update) => Effect.sync(() => { updates.push(update) }), }) - expect(updates).toEqual([ - { - structured: { stage: "capture" }, - content: [ - { type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" }, - { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, - ], - }, - ]) + expect(updates).toEqual([{ stage: "capture" }]) }), ) @@ -382,23 +388,31 @@ describe("ToolRegistry", () => { encode: SchemaGetter.transform((value) => value === "yes"), }), ) - yield* service.register({ - transformed: Tool.make({ - description: "Transform values", - input: Schema.Struct({ value: Transformed }), - output: Schema.Struct({ value: Transformed }), - execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })), - toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }], - }), - }, { codemode: false }) + yield* service.register( + { + transformed: Tool.make({ + description: "Transform values", + input: Schema.Struct({ value: Transformed }), + output: Schema.Struct({ value: Transformed }), + execute: ({ value }) => + Effect.sync(() => executed.push(value)).pipe(Effect.as({ output: { value }, content: String(value) })), + }), + }, + { codemode: false }, + ) + // Canonical content observes the decoded domain value; Code Mode observes the encoded value. expect( yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } }, }), - ).toEqual({ type: "text", value: "true" }) + ).toEqual({ + status: "completed", + output: { value: true }, + content: [{ type: "text", text: "yes" }], + }) expect(executed).toEqual(["yes"]) expect( yield* executeTool(service, { @@ -406,35 +420,44 @@ describe("ToolRegistry", () => { ...identity, call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } }, }), - ).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") }) + ).toMatchObject({ + status: "error", + error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") }, + }) expect(executed).toEqual(["yes"]) - yield* service.register({ - invalid_output: Tool.make({ - description: "Return invalid output", - input: Schema.Struct({}), - output: Schema.Struct({ - value: Schema.Boolean.pipe( - Schema.decodeTo(Schema.String, { - decode: SchemaGetter.transform((value) => String(value)), - encode: SchemaGetter.transformOrFail((value) => - value === "valid" - ? Effect.succeed(true) - : Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })), - ), - }), - ), + yield* service.register( + { + invalid_output: Tool.make({ + description: "Return invalid output", + input: Schema.Struct({}), + output: Schema.Struct({ + value: Schema.Boolean.pipe( + Schema.decodeTo(Schema.String, { + decode: SchemaGetter.transform((value) => String(value)), + encode: SchemaGetter.transformOrFail((value) => + value === "valid" + ? Effect.succeed(true) + : Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })), + ), + }), + ), + }), + execute: () => Effect.succeed({ output: { value: "invalid" } }), }), - execute: () => Effect.succeed({ value: "invalid" }), - }), - }, { codemode: false }) + }, + { codemode: false }, + ) expect( yield* executeTool(service, { sessionID, ...identity, call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} }, }), - ).toMatchObject({ type: "error", value: expect.stringContaining("invalid value for its output schema") }) + ).toMatchObject({ + status: "error", + error: { type: "tool.execution", message: expect.stringContaining("invalid value for its output schema") }, + }) }), ) @@ -443,12 +466,12 @@ describe("ToolRegistry", () => { const service = yield* ToolRegistry.Service const scope = yield* Scope.make() yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope)) - const request = yield* service.materialize() + const request = yield* service.snapshot() yield* Scope.close(scope, Exit.void) yield* service.register({ echo: constant("replacement") }, { codemode: false }) - expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" }) - expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" }) + expect((yield* request.execute(call("echo"))).content).toEqual([{ type: "text", text: "advertised" }]) + expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "replacement" }]) }), ) @@ -459,9 +482,9 @@ describe("ToolRegistry", () => { const overlay = yield* Scope.make() yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay)) - expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" }) + expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "overlay" }]) yield* Scope.close(overlay, Exit.void) - expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "base" }) + expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "base" }]) }), ) @@ -476,12 +499,13 @@ describe("ToolRegistry", () => { description: "Echo text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })), + execute: ({ text }) => + Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ output: { text } })), }), }) .pipe(Scope.provide(scope)) - const materialized = yield* service.materialize() - const execute = materialized.definitions.find((tool) => tool.name === "execute") + const toolSet = yield* service.snapshot() + const execute = toolSet.definitions.find((tool) => tool.name === "execute") expect(execute?.description).toContain("confined Code Mode runtime") expect(execute?.description).not.toContain("Echo text") yield* Scope.close(scope, Exit.void) @@ -490,11 +514,11 @@ describe("ToolRegistry", () => { description: "Echo text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })), + execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ output: { text } })), }), }) - const settlement = yield* materialized.settle({ + const execution = yield* toolSet.execute({ ...call("execute"), call: { type: "tool-call", @@ -504,7 +528,7 @@ describe("ToolRegistry", () => { }, }) - expect(settlement.result).toMatchObject({ type: "text" }) + expect(execution).toMatchObject({ status: "completed", content: [{ type: "text" }] }) expect(executed).toEqual(["old:request"]) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 86e9d7c576d..77046d6d9ff 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -52,6 +52,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigCompaction } from "@opencode-ai/core/config/compaction" import { Tool } from "@opencode-ai/core/tool/tool" +import { ToolHooks } from "@opencode-ai/core/tool/hooks" import { InstructionStateTable, SessionPendingTable, @@ -238,43 +239,45 @@ const permission = Layer.succeed( ) const echo = Layer.effectDiscard( ToolRegistry.Service.use((registry) => - registry.register({ - echo: Tool.make({ - description: "Echo text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - toModelOutput: ({ output }) => [{ type: "text", text: output.text }], - execute: ({ text }, context) => - Effect.gen(function* () { - authorizations.push(context) - executions.push(text) - activeToolExecutions++ - maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) - if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) { - yield* Deferred.succeed(toolExecutionsStarted, undefined) - } - if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) - return { text } - }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))), - }), - defect: Tool.make({ - description: "Fail unexpectedly", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => - (toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe( - Effect.andThen(Effect.die("unexpected tool defect")), - ), - }), - // BigInt output with no model content forces ToolOutputStore.bound onto its - // JSON.stringify encode path, which fails with a typed StorageError. - storefail: Tool.make({ - description: "Produce output that cannot be persisted", - input: Schema.Struct({}), - output: Schema.Any, - execute: () => Effect.succeed({ big: 1n }), - }), - }, { codemode: false }), + registry.register( + { + echo: Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }, context) => + Effect.gen(function* () { + authorizations.push(context) + executions.push(text) + activeToolExecutions++ + maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions) + if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) { + yield* Deferred.succeed(toolExecutionsStarted, undefined) + } + if (toolExecutionGate) yield* Deferred.await(toolExecutionGate) + return { output: { text }, content: text } + }).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))), + }), + defect: Tool.make({ + description: "Fail unexpectedly", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => + (toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe( + Effect.andThen(Effect.die("unexpected tool defect")), + ), + }), + // The wrapped ToolOutputStore below fails bound for this call ID with a + // typed StorageError, exercising the infrastructure failure channel. + storefail: Tool.make({ + description: "Produce output that cannot be persisted", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => Effect.succeed({ output: {} }), + }), + }, + { codemode: false }, + ), ), ) const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] }) @@ -379,6 +382,15 @@ const promptCatalog = Layer.mock(Catalog.Service, { small: () => Effect.succeed(undefined), }, }) +// Pass-through bounding that fails "call-storefail" with a typed StorageError so +// runner tests can exercise the infrastructure failure channel deterministically. +const toolOutputStore = Layer.mock(ToolOutputStore.Service, { + limits: () => Effect.succeed({ maxLines: ToolOutputStore.MAX_LINES, maxBytes: ToolOutputStore.MAX_BYTES }), + bound: (input) => + input.callID === "call-storefail" + ? Effect.fail(new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })) + : Effect.succeed({ content: input.content, outputPaths: [] }), +}) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], @@ -391,7 +403,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [PermissionV2.node, permission], [Config.node, config], [McpInstructions.node, mcpInstructions], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [ToolOutputStore.node, toolOutputStore], [PluginSupervisor.node, pluginSupervisor], ]) const execution = Layer.effect( @@ -422,6 +434,7 @@ const it = testEffect( Catalog.node, ToolRegistry.node, ToolRegistry.toolsNode, + ToolHooks.node, PluginHooks.node, echoNode, SessionRunnerModel.node, @@ -449,7 +462,7 @@ const it = testEffect( [Snapshot.node, Snapshot.noopLayer], [SessionExecution.node, execution], [Config.node, config], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [ToolOutputStore.node, toolOutputStore], [PluginSupervisor.node, pluginSupervisor], ], ), @@ -586,8 +599,8 @@ const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: Sess const settlementTypes = new Set([ "session.step.started.1", "session.tool.called.1", - "session.tool.success.1", - "session.tool.failed.1", + "session.tool.success.2", + "session.tool.failed.2", "session.step.ended.1", "session.step.failed.1", ]) @@ -827,12 +840,26 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) - expect(requests).toHaveLength(1) + // A hook-removed call fails independently and continues while step allowance remains. + expect(requests).toHaveLength(2) expect(requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"]) expect(requests[0]?.messages).toEqual([Message.user("Hooked message")]) expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo") expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered") expect(executions).toEqual([]) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Original message" }, + { + type: "assistant", + content: [ + { + type: "tool", + id: "call-removed", + state: { status: "error", error: { type: "tool.unknown" } }, + }, + ], + }, + ]) }), ) @@ -841,19 +868,22 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const registry = yield* ToolRegistry.Service const contexts: Tool.Context[] = [] - yield* registry.register({ - location_context: Tool.make({ - description: "Read application context", - input: Schema.Struct({ query: Schema.String }), - output: Schema.Struct({ answer: Schema.String }), - execute: ({ query }, context) => - Effect.gen(function* () { - contexts.push(context) - yield* context.progress({ structured: { phase: "reading" } }) - return { answer: query.toUpperCase() } - }), - }), - }, { codemode: false }) + yield* registry.register( + { + location_context: Tool.make({ + description: "Read application context", + input: Schema.Struct({ query: Schema.String }), + output: Schema.Struct({ answer: Schema.String }), + execute: ({ query }, context) => + Effect.gen(function* () { + contexts.push(context) + yield* context.progress({ phase: "reading" }) + return { output: { answer: query.toUpperCase() } } + }), + }), + }, + { codemode: false }, + ) yield* admit(session, "Use application context") responses = [reply.tool("call-location", "location_context", { query: "hello" }), []] const events = yield* EventV2.Service @@ -876,7 +906,7 @@ describe("SessionRunnerLLM", () => { progress: expect.any(Function), }, ]) - expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.structured).toEqual({ phase: "reading" }) + expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.metadata).toEqual({ phase: "reading" }) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Use application context" }, { @@ -885,7 +915,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-location", - state: { status: "completed", structured: { answer: "HELLO" } }, + state: { status: "completed", content: [{ type: "text", text: '{"answer":"HELLO"}' }] }, }, ], }, @@ -893,25 +923,29 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("persists the latest partial snapshot when a tool fails", () => + it.effect("prefers failure outcome metadata over retained progress", () => Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ - failing_progress: Tool.make({ - description: "Report progress and fail", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: (_, context) => - Effect.gen(function* () { - yield* context.progress({ - structured: { phase: "running" }, - content: [{ type: "text", text: "before failure" }], - }) - return yield* new ToolFailure({ message: "failed after progress" }) - }), - }), - }, { codemode: false }) + const hooks = yield* ToolHooks.Service + yield* hooks.hook.after((event) => { + if (event.status === "error") event.metadata = { phase: "failed" } + }) + yield* registry.register( + { + failing_progress: Tool.make({ + description: "Report progress and fail", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: (_, context) => + Effect.gen(function* () { + yield* context.progress({ phase: "running" }) + return yield* new ToolFailure({ message: "failed after progress" }) + }), + }), + }, + { codemode: false }, + ) yield* admit(session, "Run failing progress") responses = [reply.tool("call-failing-progress", "failing_progress", {}), reply.stop()] @@ -927,8 +961,7 @@ describe("SessionRunnerLLM", () => { id: "call-failing-progress", state: { status: "error", - structured: { phase: "running" }, - content: [{ type: "text", text: "before failure" }], + metadata: { phase: "failed" }, error: { message: "failed after progress" }, }, }, @@ -946,14 +979,20 @@ describe("SessionRunnerLLM", () => { const scope = yield* Scope.make() const executions: string[] = [] yield* registry - .register({ - reloaded: Tool.make({ - description: "Record the advertised tool", - input: Schema.Struct({}), - output: Schema.Struct({ value: Schema.String }), - execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })), - }), - }, { codemode: false }) + .register( + { + reloaded: Tool.make({ + description: "Record the advertised tool", + input: Schema.Struct({}), + output: Schema.Struct({ value: Schema.String }), + execute: () => + Effect.sync(() => executions.push("advertised")).pipe( + Effect.as({ output: { value: "advertised" } }), + ), + }), + }, + { codemode: false }, + ) .pipe(Scope.provide(scope)) yield* admit(session, "Use the reloaded tool") responses = [ @@ -971,14 +1010,20 @@ describe("SessionRunnerLLM", () => { const run = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) yield* Scope.close(scope, Exit.void) - yield* registry.register({ - reloaded: Tool.make({ - description: "Record the replacement tool", - input: Schema.Struct({}), - output: Schema.Struct({ value: Schema.String }), - execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })), - }), - }, { codemode: false }) + yield* registry.register( + { + reloaded: Tool.make({ + description: "Record the replacement tool", + input: Schema.Struct({}), + output: Schema.Struct({ value: Schema.String }), + execute: () => + Effect.sync(() => executions.push("replacement")).pipe( + Effect.as({ output: { value: "replacement" } }), + ), + }), + }, + { codemode: false }, + ) yield* Deferred.succeed(streamGate, undefined) yield* Fiber.join(run) @@ -991,7 +1036,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-reloaded", - state: { status: "completed", structured: { value: "advertised" } }, + state: { status: "completed", content: [{ type: "text", text: '{"value":"advertised"}' }] }, }, ], }, @@ -2377,7 +2422,6 @@ describe("SessionRunnerLLM", () => { state: { status: "completed", input: { query: "hello" }, - structured: {}, content: [ { type: "text", text: "Hello" }, { type: "file", mime: "image/png", uri: "data:image/png;base64,aGVsbG8=", name: "hello.png" }, @@ -2417,7 +2461,6 @@ describe("SessionRunnerLLM", () => { state: { status: "completed", input: { text: "hello" }, - structured: { text: "hello" }, content: [{ type: "text", text: "hello" }], }, }, @@ -2429,7 +2472,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.success.1", + "session.tool.success.2", "session.step.ended.1", ]) }), @@ -2581,7 +2624,8 @@ describe("SessionRunnerLLM", () => { type: "tool-result", id: "hosted-search", name: "web_search", - result: { type: "json", value: [{ title: "Effect" }] }, + // The generic replay result derives from canonical stored content. + result: { type: "text", value: '[{"title":"Effect"}]' }, providerExecuted: true, providerMetadata: { openai: { blockType: "web_search_tool_result" } }, }, @@ -2667,7 +2711,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "tool_0", - state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] }, + state: { status: "completed", content: [{ type: "text", text: "first" }] }, }, ], }, @@ -2677,11 +2721,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "tool_0", - state: { - status: "completed", - structured: { text: "second" }, - content: [{ type: "text", text: "second" }], - }, + state: { status: "completed", content: [{ type: "text", text: "second" }] }, }, ], }, @@ -2697,7 +2737,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "tool_0", - state: { status: "completed", structured: { text: "first" }, content: [{ type: "text", text: "first" }] }, + state: { status: "completed", content: [{ type: "text", text: "first" }] }, }, ], }, @@ -2707,11 +2747,7 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "tool_0", - state: { - status: "completed", - structured: { text: "second" }, - content: [{ type: "text", text: "second" }], - }, + state: { status: "completed", content: [{ type: "text", text: "second" }] }, }, ], }, @@ -3404,7 +3440,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.ended.1", ]) }), @@ -3414,17 +3450,20 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ - blocked: Tool.make({ - description: "Fail because policy blocked execution", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => - Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe( - Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })), - ), - }), - }, { codemode: false }) + yield* registry.register( + { + blocked: Tool.make({ + description: "Fail because policy blocked execution", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => + Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe( + Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })), + ), + }), + }, + { codemode: false }, + ) yield* admit(session, "Call blocked") responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()] @@ -3449,14 +3488,17 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ - declined: Tool.make({ - description: "Fail because the user declined approval", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => Effect.die(new PermissionV2.DeclinedError()), - }), - }, { codemode: false }) + yield* registry.register( + { + declined: Tool.make({ + description: "Fail because the user declined approval", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => Effect.die(new PermissionV2.DeclinedError()), + }), + }, + { codemode: false }, + ) yield* admit(session, "Call declined") response = reply.tool("call-declined", "declined", {}) @@ -3486,17 +3528,20 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ - corrected: Tool.make({ - description: "Fail with user correction feedback", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => - Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe( - Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })), - ), - }), - }, { codemode: false }) + yield* registry.register( + { + corrected: Tool.make({ + description: "Fail with user correction feedback", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => + Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe( + Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })), + ), + }), + }, + { codemode: false }, + ) yield* admit(session, "Call corrected") responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()] @@ -3540,13 +3585,13 @@ describe("SessionRunnerLLM", () => { status: "error", error: { type: "unknown", - message: expect.stringContaining("Failed to encode tool output"), + message: expect.stringContaining("Failed to write tool output"), }, }, }, ], finish: "error", - error: { type: "unknown", message: expect.stringContaining("Failed to encode tool output") }, + error: { type: "unknown", message: expect.stringContaining("Failed to write tool output") }, }, ]) }), @@ -3594,14 +3639,17 @@ describe("SessionRunnerLLM", () => { Effect.gen(function* () { const session = yield* setup const registry = yield* ToolRegistry.Service - yield* registry.register({ - question: Tool.make({ - description: "Ask the user", - input: Schema.Struct({}), - output: Schema.Struct({}), - execute: () => Effect.die(new QuestionTool.CancelledError()), - }), - }, { codemode: false }) + yield* registry.register( + { + question: Tool.make({ + description: "Ask the user", + input: Schema.Struct({}), + output: Schema.Struct({}), + execute: () => Effect.die(new QuestionTool.CancelledError()), + }), + }, + { codemode: false }, + ) yield* admit(session, "Ask then stop") responses = [reply.tool("call-question", "question", {}), []] @@ -3655,7 +3703,11 @@ describe("SessionRunnerLLM", () => { { type: "assistant", content: [ - { type: "tool", id: "call-before-failure", state: { status: "completed", structured: { text: "settle" } } }, + { + type: "tool", + id: "call-before-failure", + state: { status: "completed", content: [{ type: "text", text: "settle" }] }, + }, ], }, ]) @@ -3663,7 +3715,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.success.1", + "session.tool.success.2", "session.step.failed.1", ]) }), @@ -3707,7 +3759,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.failed.1", ]) @@ -3808,7 +3860,8 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) expect(requests[0]?.toolChoice).toBeUndefined() expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) - expect(requests[1]?.tools).toEqual([]) + // Protocols with native "none" keep these definitions for prompt caching. + expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo") expect(requests[1]?.messages.at(-1)).toMatchObject({ role: "assistant", content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], @@ -3953,7 +4006,7 @@ describe("SessionRunnerLLM", () => { expect(events.map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.success.1", + "session.tool.success.2", "session.step.failed.1", ]) expect( @@ -4146,7 +4199,8 @@ describe("SessionRunnerLLM", () => { content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], }) expect(requests[2]?.toolChoice).toMatchObject({ type: "none" }) - expect(requests[2]?.tools).toEqual([]) + // The final step keeps tool definitions to preserve provider prompt caching. + expect(requests[2]?.tools.map((tool) => tool.name)).toContain("echo") expect(requests[2]?.messages.at(-1)).toMatchObject({ role: "assistant", content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], @@ -4197,7 +4251,7 @@ describe("SessionRunnerLLM", () => { expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([ { type: "session.step.started.1" }, { - type: "session.tool.failed.1", + type: "session.tool.failed.2", data: { callID: "call-malformed", error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" }, @@ -4292,7 +4346,7 @@ describe("SessionRunnerLLM", () => { expect(failed.error).toBeUndefined() expect((yield* recordedStepSettlementEvents(sessionID, failed.id)).map((event) => event.type)).toEqual([ "session.step.started.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.ended.1", ]) const database = (yield* Database.Service).db @@ -4521,7 +4575,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) expect(requests[0]?.toolChoice).toBeUndefined() expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) - expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.1")).toHaveLength(2) + expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.tool.failed.2")).toHaveLength(2) }), ) @@ -4553,7 +4607,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.success.1", + "session.tool.success.2", "session.step.failed.1", ]) }), @@ -4585,7 +4639,7 @@ describe("SessionRunnerLLM", () => { expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.failed.1", ]) }), @@ -4609,7 +4663,7 @@ describe("SessionRunnerLLM", () => { expect(events.map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.failed.1", ]) expect(events[2]?.data.error).toMatchObject({ type: "unknown", message: "unexpected tool defect" }) @@ -4646,7 +4700,7 @@ describe("SessionRunnerLLM", () => { expect(events.map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.failed.1", ]) expect( @@ -4684,7 +4738,7 @@ describe("SessionRunnerLLM", () => { expect(events.map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.ended.1", ]) expect( @@ -4721,8 +4775,8 @@ describe("SessionRunnerLLM", () => { { type: "session.step.started.1", callID: undefined }, { type: "session.tool.called.1", callID: "call-local-raw-failure" }, { type: "session.tool.called.1", callID: "call-hosted-raw-failure-pair" }, - { type: "session.tool.failed.1", callID: "call-local-raw-failure" }, - { type: "session.tool.failed.1", callID: "call-hosted-raw-failure-pair" }, + { type: "session.tool.failed.2", callID: "call-local-raw-failure" }, + { type: "session.tool.failed.2", callID: "call-hosted-raw-failure-pair" }, { type: "session.step.failed.1", callID: undefined }, ]) expect( @@ -4748,7 +4802,7 @@ describe("SessionRunnerLLM", () => { expect(events.map((event) => event.type)).toEqual([ "session.step.started.1", "session.tool.called.1", - "session.tool.failed.1", + "session.tool.failed.2", "session.step.failed.1", ]) expect( diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index cc81820e39f..414e260e984 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -84,30 +84,29 @@ describe("Tool.Progress", () => { yield* start("call-success") expect((yield* readAssistant).content[0]).toMatchObject({ - state: { status: "running", structured: {}, content: [] }, + state: { status: "running", metadata: {} }, }) const progress = yield* service.publish(SessionEvent.Tool.Progress, { sessionID, assistantMessageID, callID: "call-success", - structured: { phase: "checkpoint" }, - content: content("saved"), + metadata: { phase: "checkpoint" }, }) expect((yield* readAssistant).content[0]).toMatchObject({ - state: { status: "running", structured: {}, content: [] }, + state: { status: "running", metadata: {} }, }) const success = yield* service.publish(SessionEvent.Tool.Success, { sessionID, assistantMessageID, callID: "call-success", - structured: { phase: "done" }, + metadata: { phase: "done" }, content: content("complete"), executed: false, }) expect((yield* readAssistant).content[0]).toMatchObject({ - state: { status: "completed", structured: { phase: "done" }, content: content("complete") }, + state: { status: "completed", metadata: { phase: "done" }, content: content("complete") }, }) yield* start("call-failed") @@ -115,8 +114,7 @@ describe("Tool.Progress", () => { sessionID, assistantMessageID, callID: "call-failed", - structured: { phase: "checkpoint" }, - content: content("before failure"), + metadata: { phase: "checkpoint" }, }) const failed = yield* service.publish(SessionEvent.Tool.Failed, { sessionID, @@ -130,7 +128,7 @@ describe("Tool.Progress", () => { expect((yield* readAssistant).content[1]).toMatchObject({ state: { status: "error", - structured: { phase: "checkpoint" }, + metadata: { phase: "checkpoint" }, content: content("before failure"), error: { type: "unknown", message: "boom" }, }, @@ -147,8 +145,8 @@ describe("Tool.Progress", () => { .all() .pipe(Effect.orDie) expect(rows.map((row) => row.type)).not.toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1)) - expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 1)) - expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 1)) + expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 2)) + expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 2)) }), ) }) diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index aaecf9766fd..446e6aecf55 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -18,7 +18,7 @@ import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const editToolNode = makeLocationNode({ name: "test/edit-tool-plugin", @@ -141,15 +141,23 @@ describe("EditTool", () => { expect(yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).toEqual( [], ) - const settled = yield* settleTool( + const settled = yield* executeTool( registry, call({ path: "hello.txt", oldString: "before", newString: "after" }), ) - expect(settled.result).toEqual({ - type: "text", - value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```", + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.content).toEqual([ + { + type: "text", + text: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```", + }, + ]) + // Compact UI metadata carries the file diffs the TUI renders. + expect(settled.metadata).toMatchObject({ + files: [{ file: "hello.txt", status: "modified", additions: 1, deletions: 1 }], }) - expect(settled.output?.structured).toEqual({ + expect(settled.output).toEqual({ replacements: 1, files: [ { @@ -187,7 +195,7 @@ describe("EditTool", () => { ), Effect.andThen((result) => Effect.gen(function* () { - expect(result.type).toBe("text") + expect(result.status).toBe("completed") expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after") }), @@ -217,7 +225,7 @@ describe("EditTool", () => { ), Effect.andThen((result) => Effect.sync(() => { - expect(result.type).toBe("text") + expect(result.status).toBe("completed") expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(assertions[0]?.resources).toEqual(["link.txt"]) }), @@ -247,7 +255,7 @@ describe("EditTool", () => { ), Effect.andThen((result) => Effect.gen(function* () { - expect(result.type).toBe("text") + expect(result.status).toBe("completed") expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after") expect(writes).toHaveLength(1) @@ -276,8 +284,8 @@ describe("EditTool", () => { executeTool(registry, call({ path: external, oldString: "before", newString: "after" })), ), ).toEqual({ - type: "error", - value: `Unable to edit ${external}`, + status: "error", + error: { type: "permission.rejected", message: "Permission denied: external_directory" }, }) expect(assertions.map((input) => input.action)).toEqual(["external_directory"]) expect(reads).toBe(0) @@ -290,8 +298,8 @@ describe("EditTool", () => { executeTool(registry, call({ path: external, oldString: "before", newString: "after" })), ), ).toEqual({ - type: "error", - value: `Unable to edit ${external}`, + status: "error", + error: { type: "permission.rejected", message: "Permission denied: edit" }, }) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(reads).toBe(0) @@ -325,7 +333,10 @@ describe("EditTool", () => { call({ path: "secret.txt", oldString: "not present", newString: "replacement" }), ) - expect(matching).toEqual({ type: "error", value: "Unable to edit secret.txt" }) + expect(matching).toEqual({ + status: "error", + error: { type: "permission.rejected", message: "Permission denied: edit" }, + }) expect(missing).toEqual(matching) expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"]) expect(reads).toBe(0) @@ -352,28 +363,40 @@ describe("EditTool", () => { expect( yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })), ).toEqual({ - type: "error", - value: "No changes to apply: oldString and newString are identical.", + status: "error", + error: { + type: "tool.execution", + message: "No changes to apply: oldString and newString are identical.", + }, }) expect( yield* executeTool(registry, call({ path: "matches.txt", oldString: "", newString: "after" })), ).toEqual({ - type: "error", - value: "oldString must not be empty. Use write to create or overwrite a file.", + status: "error", + error: { + type: "tool.execution", + message: "oldString must not be empty. Use write to create or overwrite a file.", + }, }) expect( yield* executeTool(registry, call({ path: "matches.txt", oldString: "missing", newString: "after" })), ).toEqual({ - type: "error", - value: - "Could not find oldString in the file. It must match exactly, including whitespace and indentation.", + status: "error", + error: { + type: "tool.execution", + message: + "Could not find oldString in the file. It must match exactly, including whitespace and indentation.", + }, }) expect( yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })), ).toEqual({ - type: "error", - value: - "Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.", + status: "error", + error: { + type: "tool.execution", + message: + "Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.", + }, }) expect(writes).toEqual([]) }), @@ -394,12 +417,14 @@ describe("EditTool", () => { return Effect.promise(() => fs.writeFile(target, "same same same")).pipe( Effect.andThen( withTool(tmp.path, (registry) => - settleTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })), + executeTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })), ), ), Effect.andThen((settled) => Effect.gen(function* () { - expect(settled.output?.structured).toMatchObject({ replacements: 3 }) + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.output).toMatchObject({ replacements: 3 }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after") expect(writes).toHaveLength(1) }), @@ -445,9 +470,14 @@ describe("EditTool", () => { ), Effect.andThen((result) => Effect.gen(function* () { + // The message-less StaleContentError cause must not erase the tool's + // curated failure message; the canonical error is the sole authority. expect(result).toEqual({ - type: "error", - value: "File changed after permission approval. Read it again before editing.", + status: "error", + error: { + type: "tool.execution", + message: "File changed after permission approval. Read it again before editing.", + }, }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("newer\n") expect(writes).toEqual([]) diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index 66254ec408d..84df02243ec 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -6,6 +6,69 @@ import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "@opencode-ai/schema/session-message" import { Effect, Schema } from "effect" +const context = { + sessionID: Session.ID.make("ses_execute"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_execute"), + callID: "call_execute", + progress: () => Effect.void, +} + +test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => { + const declared = Tool.make({ + description: "Declared", + input: Schema.Struct({ value: Schema.String }), + output: Schema.Struct({ value: Schema.String }), + execute: ({ value }) => Effect.succeed({ output: { value } }), + }) + const modelOnly = Tool.make({ + description: "Model only", + input: Schema.Struct({}), + execute: () => Effect.succeed({ content: "visible only", metadata: { kind: "model" } }), + }) + const raw = Tool.make({ + description: "Raw", + input: {}, + output: {}, + execute: (input) => Effect.succeed({ output: input, content: "raw" }), + }) + + expect(await Effect.runPromise(Tool.execute(declared, { value: "encoded" }, context))).toEqual({ + output: { value: "encoded" }, + content: [{ type: "text", text: '{"value":"encoded"}' }], + }) + expect(await Effect.runPromise(Tool.execute(modelOnly, {}, context))).toEqual({ + content: [{ type: "text", text: "visible only" }], + metadata: { kind: "model" }, + }) + expect(await Effect.runPromise(Tool.execute(raw, { unchecked: true }, context))).toEqual({ + output: { unchecked: true }, + content: [{ type: "text", text: "raw" }], + }) +}) + +test("declared outputs cannot bypass validation and raw outputs stay JSON-compatible", async () => { + const missing: Tool.Any = { + description: "Missing output", + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed({ content: "not an output" }), + } + const invalid: Tool.Any = { + description: "Invalid raw output", + input: {}, + output: {}, + execute: () => Effect.succeed({ output: 1n, content: "not JSON" }), + } + + expect((await Effect.runPromiseExit(Tool.execute(missing, {}, context))).toString()).toContain( + "Tool did not return its declared output", + ) + expect((await Effect.runPromiseExit(Tool.execute(invalid, {}, context))).toString()).toContain( + "Tool returned a non-JSON value", + ) +}) + test("execute preserves successful results with visible unhandled rejections", async () => { const child = Tool.make({ description: "Always fail", @@ -13,27 +76,10 @@ test("execute preserves successful results with visible unhandled rejections", a output: Schema.String, execute: () => Effect.fail(new Tool.Failure({ message: "Lookup refused" })), }) - const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail" }]])) - const result = await Effect.runPromise( - Tool.settle( - execute, - { - type: "tool-call", - id: "call_execute", - name: "execute", - input: { code: `tools.fail({}); return "done"` }, - }, - { - sessionID: Session.ID.make("ses_execute"), - agent: Agent.ID.make("build"), - messageID: SessionMessage.ID.make("msg_execute"), - callID: "call_execute", - progress: () => Effect.void, - }, - ), - ) + const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail", permission: "fail" }]])) + const result = await Effect.runPromise(Tool.execute(execute, { code: `tools.fail({}); return "done"` }, context)) - expect(result.structured).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] }) + expect(result.metadata).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] }) expect(result.content).toEqual([ { type: "text", @@ -52,40 +98,32 @@ test("execute supports callable namespace tools", async () => { description: "Administer Slack", input: Schema.Struct({}), output: Schema.String, - execute: () => Effect.succeed("admin"), + execute: () => Effect.succeed({ output: "admin" }), }) const child = Tool.make({ description: "Create a Slack resource", input: Schema.Struct({}), output: Schema.String, - execute: () => Effect.succeed("created"), + execute: () => Effect.succeed({ output: "created" }), }) const execute = ExecuteTool.create( new Map([ - ["slack_admin", { tool: callable, name: "admin", namespace: "slack" }], - ["slack_admin_create", { tool: child, name: "create", namespace: "slack.admin" }], + ["slack_admin", { tool: callable, name: "admin", namespace: "slack", permission: "slack_admin" }], + [ + "slack_admin_create", + { tool: child, name: "create", namespace: "slack.admin", permission: "slack_admin_create" }, + ], ]), ) const result = await Effect.runPromise( - Tool.settle( + Tool.execute( execute, - { - type: "tool-call", - id: "call_execute", - name: "execute", - input: { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" }, - }, - { - sessionID: Session.ID.make("ses_execute"), - agent: Agent.ID.make("build"), - messageID: SessionMessage.ID.make("msg_execute"), - callID: "call_execute", - progress: () => Effect.void, - }, + { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" }, + context, ), ) - expect(result.structured).toEqual({ + expect(result.metadata).toEqual({ toolCalls: [ { tool: "slack.admin", status: "completed" }, { tool: "slack.admin.create", status: "completed" }, diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index d8a872ce602..2e22402e104 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -53,52 +53,31 @@ describe("ToolOutputStore", () => { const result = yield* store.bound({ sessionID, callID: "call-aggregate", - output: { - structured: { kind: "report" }, - content: [ - { type: "text", text: first }, - { type: "text", text: second }, - ], - }, + content: [ + { type: "text", text: first }, + { type: "text", text: second }, + ], }) - expect(result.output.structured).toEqual({ kind: "report" }) expect(result.outputPaths).toHaveLength(1) expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + second) - if (result.output.content[0]?.type !== "text") throw new Error("expected text preview") - expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES) + if (result.content[0]?.type !== "text") throw new Error("expected text preview") + expect(Buffer.byteLength(result.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES) }), ), ) - it.live("uses bounded text for oversized structured-only output", () => - withStore(({ store, fs }) => - Effect.gen(function* () { - const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) } - const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } }) - expect(result.output.structured).toEqual(structured) - expect(result.outputPaths).toHaveLength(1) - expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured) - expect(result.output.content).toHaveLength(1) - }), - ), - ) - - it.live("preserves native media and structured metadata without applying a settlement media limit", () => + it.live("preserves native media without applying an execution media limit", () => withStore(({ store }) => Effect.gen(function* () { const data = "a".repeat(6 * 1024 * 1024) const result = yield* store.bound({ sessionID, callID: "call-file", - output: { - structured: { caption: "pixel" }, - content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }], - }, + content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }], }) expect(result.outputPaths).toEqual([]) - expect(result.output.structured).toEqual({ caption: "pixel" }) - expect(result.output.content).toHaveLength(1) - expect(result.output.content[0]).toEqual({ + expect(result.content).toHaveLength(1) + expect(result.content[0]).toEqual({ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", @@ -108,7 +87,7 @@ describe("ToolOutputStore", () => { ), ) - it.live("preserves structured metadata and native media when bounding text", () => + it.live("preserves native media when bounding text", () => withStore(({ store, fs }) => Effect.gen(function* () { const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1) @@ -121,30 +100,29 @@ describe("ToolOutputStore", () => { const result = yield* store.bound({ sessionID, callID: "call-text-and-media", - output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] }, + content: [{ type: "text", text }, media], }) - expect(result.output.structured).toEqual({ caption: "pixel" }) - expect(result.output.content[1]).toEqual(media) + expect(result.content[1]).toEqual(media) expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text) }), ), ) - it.live("does not double-count structured data duplicated in projected text", () => + it.live("returns content within the limits unchanged", () => withStore(({ store }) => Effect.gen(function* () { const text = "x".repeat(30_000) - const output = { structured: { output: text }, content: [{ type: "text" as const, text }] } - expect(yield* store.bound({ sessionID, callID: "call-duplicated", output })).toEqual({ - output, + const content = [{ type: "text" as const, text }] + expect(yield* store.bound({ sessionID, callID: "call-duplicated", content })).toEqual({ + content, outputPaths: [], }) }), ), ) - it.live("fails oversized settlement when complete retention cannot be written", () => + it.live("fails oversized execution when complete retention cannot be written", () => withStore(({ root, store, fs }) => Effect.gen(function* () { yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory") @@ -152,7 +130,7 @@ describe("ToolOutputStore", () => { .bound({ sessionID, callID: "call-lossy", - output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] }, + content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }], }) .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) @@ -162,18 +140,6 @@ describe("ToolOutputStore", () => { ), ) - it.live("does not encode ignored structured metadata when projected content exists", () => - withStore(({ store }) => - Effect.gen(function* () { - const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] } - expect(yield* store.bound({ sessionID, callID: "call-unencodable", output })).toEqual({ - output, - outputPaths: [], - }) - }), - ), - ) - it.live("preserves interruption while retaining complete output", () => Effect.gen(function* () { const root = yield* Effect.promise(() => tmpdir()) @@ -198,7 +164,7 @@ describe("ToolOutputStore", () => { .bound({ sessionID, callID: "call-interrupted", - output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] }, + content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }], }) .pipe(Effect.forkChild) yield* Fiber.interrupt(fiber) @@ -217,7 +183,7 @@ describe("ToolOutputStore", () => { const result = yield* store.bound({ sessionID, callID: "call-config", - output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] }, + content: [{ type: "text", text: "one\ntwo\nthree" }], }) expect(result.outputPaths).toHaveLength(1) }), diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 86ae95e1837..cfae81eef86 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -16,7 +16,7 @@ import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const patchToolNode = makeLocationNode({ name: "test/patch-tool-plugin", @@ -96,29 +96,19 @@ const withTool = ( const activeLocation = Layer.succeed( Location.Service, Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { projectDirectory: AbsolutePath.make(projectDirectory) }, - ), + location({ directory: AbsolutePath.make(directory) }, { projectDirectory: AbsolutePath.make(projectDirectory) }), ), ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) }).pipe( Effect.provide( - AppNodeBuilder.build( - LayerNode.group([ - ToolRegistry.node, - ToolRegistry.toolsNode, - patchToolNode, - ]), - [ - [FSUtil.node, filesystem], - [Location.node, activeLocation], - [PermissionV2.node, permission], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ], - ), + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, patchToolNode]), [ + [FSUtil.node, filesystem], + [Location.node, activeLocation], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), ), ) } @@ -162,18 +152,23 @@ describe("PatchTool", () => { withTool(tmp.path, (registry) => Effect.gen(function* () { expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["patch"]) - const settled = yield* settleTool( + const settled = yield* executeTool( registry, call( "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch", ), ) - expect(settled.result).toEqual({ - type: "text", - value: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt", - }) - if (process.platform === "win32") expect(settled.result.value).not.toContain("\\") - expect(settled.output?.structured).toMatchObject({ + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.content).toEqual([ + { + type: "text", + text: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt", + }, + ]) + const modelText = settled.content[0]?.type === "text" ? settled.content[0].text : "" + if (process.platform === "win32") expect(modelText).not.toContain("\\") + expect(settled.output).toMatchObject({ applied: [ { type: "add", resource: "nested/new.txt" }, { type: "update", resource: "update.txt" }, @@ -248,9 +243,11 @@ describe("PatchTool", () => { "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch", ), ), - ).toEqual({ - type: "text", - value: "Success. Updated the following files:\nA created.txt\nM moved.txt", + ).toMatchObject({ + status: "completed", + content: [ + { type: "text", text: "Success. Updated the following files:\nA created.txt\nM moved.txt" }, + ], }) expect(yield* exists(source)).toBe(false) expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe( @@ -278,7 +275,9 @@ describe("PatchTool", () => { return Effect.promise(() => Promise.all([ fs.writeFile(source, "before\n"), - fs.mkdir(path.dirname(destination), { recursive: true }).then(() => fs.writeFile(destination, "existing\n")), + fs + .mkdir(path.dirname(destination), { recursive: true }) + .then(() => fs.writeFile(destination, "existing\n")), ]), ).pipe( Effect.andThen( @@ -291,7 +290,7 @@ describe("PatchTool", () => { "*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch", ), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(yield* exists(source)).toBe(false) expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n") }), @@ -325,19 +324,21 @@ describe("PatchTool", () => { ), ) - it.live("includes move file info in structured output", () => + it.live("includes move file info in output and metadata", () => withTempTool((directory, registry) => Effect.gen(function* () { const source = path.join(directory, "old", "name.txt") yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true })) yield* Effect.promise(() => fs.writeFile(source, "old content\n")) - const settled = yield* settleTool( + const settled = yield* executeTool( registry, call( "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch", ), ) - expect(settled.output?.structured).toMatchObject({ + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.output).toMatchObject({ applied: [{ type: "update", resource: "renamed/dir/name.txt" }], files: [ { @@ -393,7 +394,7 @@ describe("PatchTool", () => { yield* Effect.promise(() => fs.mkdir(path.join(directory, "dir"))) expect( yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: dir\n*** End Patch")), - ).toMatchObject({ type: "error" }) + ).toMatchObject({ status: "error" }) expect(yield* exists(path.join(directory, "dir"))).toBe(true) }), ), @@ -407,11 +408,9 @@ describe("PatchTool", () => { expect( yield* executeTool( registry, - call( - "*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch", - ), + call("*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"), ), - ).toMatchObject({ type: "error" }) + ).toMatchObject({ status: "error" }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nb\nc\nd\n") }), ), @@ -420,7 +419,10 @@ describe("PatchTool", () => { it.live("requires patchText", () => withTempTool((_directory, registry) => Effect.gen(function* () { - expect(yield* executeTool(registry, call(""))).toEqual({ type: "error", value: "patchText is required" }) + expect(yield* executeTool(registry, call(""))).toEqual({ + status: "error", + error: { type: "tool.execution", message: "patchText is required" }, + }) }), ), ) @@ -429,12 +431,18 @@ describe("PatchTool", () => { withTempTool((_directory, registry) => Effect.gen(function* () { expect(yield* executeTool(registry, call("invalid patch"))).toEqual({ - type: "error", - value: "patch verification failed: The first line of the patch must be '*** Begin Patch'", + status: "error", + error: { + type: "tool.execution", + message: "patch verification failed: The first line of the patch must be '*** Begin Patch'", + }, }) expect(yield* executeTool(registry, call("*** Begin Patch\n*** Add File: foo\n+hello"))).toEqual({ - type: "error", - value: "patch verification failed: The last line of the patch must be '*** End Patch'", + status: "error", + error: { + type: "tool.execution", + message: "patch verification failed: The last line of the patch must be '*** End Patch'", + }, }) }), ), @@ -444,8 +452,8 @@ describe("PatchTool", () => { withTempTool((_directory, registry) => Effect.gen(function* () { expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({ - type: "error", - value: "patch rejected: empty patch", + status: "error", + error: { type: "tool.execution", message: "patch rejected: empty patch" }, }) }), ), @@ -454,15 +462,13 @@ describe("PatchTool", () => { it.live("rejects an invalid hunk header", () => withTempTool((_directory, registry) => Effect.gen(function* () { - expect( - yield* executeTool( - registry, - call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"), - ), - ).toEqual({ - type: "error", - value: - "patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'", + expect(yield* executeTool(registry, call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"))).toEqual({ + status: "error", + error: { + type: "tool.execution", + message: + "patch verification failed: Invalid hunk at line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'", + }, }) }), ), @@ -490,13 +496,13 @@ describe("PatchTool", () => { const bom = "\uFEFF" const target = path.join(directory, "example.cs") yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`)) - const settled = yield* settleTool( + const settled = yield* executeTool( registry, - call( - "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch", - ), + call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"), ) - const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured) + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output) expect(output.files[0]?.patch).not.toContain(bom) expect(output.files[0]?.patch).not.toContain("-using System;") expect(output.files[0]?.patch).not.toContain("+using System;") @@ -517,7 +523,10 @@ describe("PatchTool", () => { registry, call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"), ), - ).toMatchObject({ type: "error", value: expect.stringContaining("Failed to find expected lines") }) + ).toMatchObject({ + status: "error", + error: { message: expect.stringContaining("Failed to find expected lines") }, + }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n") }), ), @@ -532,10 +541,12 @@ describe("PatchTool", () => { call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"), ), ).toMatchObject({ - type: "error", - value: expect.stringContaining( - `patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `, - ), + status: "error", + error: { + message: expect.stringContaining( + `patch verification failed: Failed to read file to update ${path.join(directory, "missing.txt")}: `, + ), + }, }) }), ), @@ -548,8 +559,11 @@ describe("PatchTool", () => { expect( yield* executeTool(registry, call("*** Begin Patch\n*** Update File: nested\n@@\n-old\n+new\n*** End Patch")), ).toEqual({ - type: "error", - value: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`, + status: "error", + error: { + type: "tool.execution", + message: `patch verification failed: Failed to read file to update ${path.join(directory, "nested")}: path is a directory`, + }, }) }), ), @@ -560,7 +574,7 @@ describe("PatchTool", () => { Effect.gen(function* () { expect( yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")), - ).toMatchObject({ type: "error", value: expect.stringContaining("patch verification failed") }) + ).toMatchObject({ status: "error", error: { message: expect.stringContaining("patch verification failed") } }) }), ), ) @@ -580,7 +594,7 @@ describe("PatchTool", () => { registry, call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(readsBeforeEditApproval).toBe(1) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") @@ -614,7 +628,7 @@ describe("PatchTool", () => { registry, call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`), ), - ).toMatchObject({ type: "error" }) + ).toMatchObject({ status: "error" }) expect(assertions.map((input) => input.action)).toEqual(["external_directory"]) expect(readsBeforeEditApproval).toBe(0) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n") @@ -649,7 +663,7 @@ describe("PatchTool", () => { registry, call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") }), @@ -680,7 +694,7 @@ describe("PatchTool", () => { registry, call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") }), @@ -711,7 +725,7 @@ describe("PatchTool", () => { registry, call(`*** Begin Patch\n*** Update File: ${relative}\n@@\n-before\n+after\n*** End Patch`), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) expect(readsBeforeEditApproval).toBe(1) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") @@ -747,7 +761,7 @@ describe("PatchTool", () => { `*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`, ), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(assertions.map((input) => input.action)).toEqual([ "external_directory", "external_directory", @@ -786,8 +800,10 @@ describe("PatchTool", () => { ), ), ).toMatchObject({ - type: "error", - value: expect.stringContaining("patch verification failed: Failed to read file to update"), + status: "error", + error: { + message: expect.stringContaining("patch verification failed: Failed to read file to update"), + }, }) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) }), @@ -812,7 +828,7 @@ describe("PatchTool", () => { registry, call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n") }), ), @@ -837,7 +853,7 @@ describe("PatchTool", () => { registry, call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"), ), - ).toMatchObject({ type: "text" }) + ).toMatchObject({ status: "completed" }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n") }), ) @@ -876,5 +892,4 @@ describe("PatchTool", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), ) - }) diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 002806ec49c..9a72bca1b5e 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -12,7 +12,7 @@ import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_question_tool_test") const assertions: PermissionV2.AssertInput[] = [] @@ -99,13 +99,13 @@ describe("QuestionTool", () => { expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([]) expect( - yield* settleTool(registry, { + yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput }, }), ).toEqual({ - result: { type: "error", value: "Permission denied: question" }, + status: "error", error: { type: "permission.rejected", message: "Permission denied: question", @@ -144,26 +144,21 @@ describe("QuestionTool", () => { expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"]) expect( - yield* settleTool(registry, { + yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-question", name: "question", input: { questions } }, }), ).toEqual({ - result: { - type: "text", - value: - 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.', - }, - output: { - structured: { answers: [["Build"], ["Dev"], []] }, - content: [ - { - type: "text", - text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.', - }, - ], - }, + status: "completed", + output: { answers: [["Build"], ["Dev"], []] }, + content: [ + { + type: "text", + text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.', + }, + ], + metadata: { answers: [["Build"], ["Dev"], []] }, }) expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }]) expect(capturedInput()).toEqual({ diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index 6d309e38348..6b3d084a10a 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -22,7 +22,7 @@ import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { SessionInstructions } from "@opencode-ai/core/session/instructions" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const readToolNode = makeLocationNode({ name: "test/read-tool-plugin", @@ -199,21 +199,19 @@ describe("ReadTool", () => { expect(yield* toolDefinitions(registry)).toMatchObject([{ name: "read" }]) expect(yield* toolDefinitions(registry, [{ action: "read", resource: "*", effect: "deny" }])).toEqual([]) - expect( - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, - }), - ).toEqual({ - type: "json", - value: { - uri: "file:///README.md", - name: "README.md", - content: "hello", - encoding: "utf8", - mime: "text/plain", - }, + const execution = yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, + }) + expect(execution.status).toBe("completed") + if (execution.status !== "completed") return + expect(execution.output).toEqual({ + uri: "file:///README.md", + name: "README.md", + content: "hello", + encoding: "utf8", + mime: "text/plain", }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["README.md"], save: ["*"] }]) expect(readCalls).toEqual([ @@ -236,7 +234,7 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-external-read", name: "read", input: { path: external } }, }), - ).toMatchObject({ type: "json" }) + ).toMatchObject({ status: "completed" }) expect(assertions).toMatchObject([ { sessionID, @@ -261,19 +259,17 @@ describe("ReadTool", () => { } const registry = yield* ToolRegistry.Service - expect( - yield* executeTool(registry, { - sessionID, - ...toolIdentity, - call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } }, - }), - ).toEqual({ - type: "content", - value: [ - { type: "text", text: "Image read successfully" }, - { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" }, - ], + const execution = yield* executeTool(registry, { + sessionID, + ...toolIdentity, + call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } }, }) + expect(execution.status).toBe("completed") + if (execution.status !== "completed") return + expect(execution.content).toEqual([ + { type: "text", text: "Image read successfully" }, + { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "pixel.png" }, + ]) expect(readCalls).toEqual([ { input: AbsolutePath.make(path.join(process.cwd(), "pixel.png")), @@ -281,21 +277,17 @@ describe("ReadTool", () => { }, ]) - const settled = yield* settleTool(registry, { + const settled = yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } }, }) - expect(settled.output?.structured).toMatchObject({ - uri: "file:///pixel.png", - name: "pixel.png", - mime: "image/png", - encoding: "base64", - // Image base64 is carried by the content file item only; structured is slimmed - // so the original bytes are never persisted twice. - content: "", - }) - expect(settled.output?.content).toMatchObject([ + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + // Image base64 is carried by the content file item only; read produces no + // metadata, so the original bytes are never persisted twice. + expect(settled.metadata).toBeUndefined() + expect(settled.content).toMatchObject([ { type: "text", text: "Image read successfully" }, { type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` }, ]) @@ -319,26 +311,25 @@ describe("ReadTool", () => { } const registry = yield* ToolRegistry.Service - const settled = yield* settleTool(registry, { + const settled = yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-large-image", name: "read", input: { path: "large.png" } }, }) expect(settled.outputPaths).toBeUndefined() - expect(settled.output?.structured).toMatchObject({ + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.output).toMatchObject({ uri: "file:///large.png", name: "large.png", mime: "image/png", encoding: "base64", }) - expect(settled.result).toEqual({ - type: "content", - value: [ - { type: "text", text: "Image read successfully" }, - { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" }, - ], - }) + expect(settled.content).toEqual([ + { type: "text", text: "Image read successfully" }, + { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png", name: "large.png" }, + ]) }), ) @@ -361,13 +352,13 @@ describe("ReadTool", () => { call: { type: "tool-call", id: "call-image-fallback", name: "read", input: { path: "pixel.png" } }, }), ).toMatchObject({ - type: "content", - value: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }], + status: "completed", + content: [{ type: "text" }, { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }], }) }), ) - it.effect("drops undecodable image data at settlement", () => + it.effect("drops undecodable image data from the outcome", () => Effect.gen(function* () { readResult = { uri: "file:///truncated.png", @@ -384,9 +375,9 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } }, }), - ).toEqual({ - type: "content", - value: [ + ).toMatchObject({ + status: "completed", + content: [ { type: "text", text: "Image read successfully" }, { type: "text", text: "[1 image omitted: could not be decoded.]" }, ], @@ -394,7 +385,7 @@ describe("ReadTool", () => { }), ) - it.effect("drops oversized images at settlement when resizing is disabled", () => + it.effect("drops oversized images from the outcome when resizing is disabled", () => Effect.gen(function* () { const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1) @@ -425,9 +416,9 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } }, }), - ).toEqual({ - type: "content", - value: [ + ).toMatchObject({ + status: "completed", + content: [ { type: "text", text: "Image read successfully" }, { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, ], @@ -463,9 +454,9 @@ describe("ReadTool", () => { call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } }, }) - expect(result.type).toBe("content") - if (result.type !== "content") return - const media = result.value[1] + expect(result.status).toBe("completed") + if (result.status !== "completed") return + const media = result.content[1] expect(media?.type).toBe("file") if (media?.type !== "file") return const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.uri.split(",")[1] ?? "", "base64")) @@ -503,9 +494,9 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } }, }), - ).toEqual({ - type: "content", - value: [ + ).toMatchObject({ + status: "completed", + content: [ { type: "text", text: "Image read successfully" }, { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" }, ], @@ -532,8 +523,8 @@ describe("ReadTool", () => { call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } }, }), ).toMatchObject({ - type: "content", - value: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }], + status: "completed", + content: [{ type: "text" }, { type: "file", mime: "image/png", name: "pixel.bin" }], }) }), ) @@ -554,7 +545,7 @@ describe("ReadTool", () => { input: { path: "archive.dat", offset: 2, limit: 1 }, }, }), - ).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" }) + ).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: archive.dat" } }) expect(readCalls).toEqual([ { input: AbsolutePath.make(path.join(process.cwd(), "archive.dat")), page: { offset: 2, limit: 1 } }, ]) @@ -589,7 +580,7 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md" } }, }), - ).toEqual({ type: "error", value: "Unable to read README.md" }) + ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } }) expect(readCalls).toEqual([]) }), ) @@ -604,7 +595,9 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-missing-path", name: "read", input: { path: missingPath } }, }), - ).toEqual({ type: "error", value: `Unable to read ${missingPath}` }) + // The message-less PathError cause must not erase the tool's curated + // failure message; the canonical error is the sole authority. + ).toEqual({ status: "error", error: { type: "tool.execution", message: `Unable to read ${missingPath}` } }) expect(assertions).toEqual([]) expect(readCalls).toEqual([]) }), @@ -626,7 +619,7 @@ describe("ReadTool", () => { input: { path: "src", offset: 2, limit: 10 }, }, }), - ).toEqual({ type: "json", value: { entries: [], truncated: false } }) + ).toMatchObject({ status: "completed", output: { entries: [], truncated: false } }) expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }]) expect(listCalls).toEqual([{ offset: 2, limit: 10 }]) }), @@ -644,7 +637,7 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } }, }), - ).toEqual({ type: "error", value: "Unable to read src" }) + ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } }) expect(listCalls).toEqual([]) }), ) @@ -691,9 +684,9 @@ describe("ReadTool", () => { input: { path: "large.txt", offset: 2, limit: 1 }, }, }), - ).toEqual({ - type: "json", - value: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, + ).toMatchObject({ + status: "completed", + output: { type: "text-page", content: "hello", mime: "text/plain", offset: 2, truncated: true, next: 3 }, }) expect(readCalls).toEqual([ { input: AbsolutePath.make(path.join(process.cwd(), "large.txt")), page: { offset: 2, limit: 1 } }, @@ -718,7 +711,7 @@ describe("ReadTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } }, }), - ).toEqual({ type: "error", value: "Cannot read binary file: late-binary" }) + ).toEqual({ status: "error", error: { type: "unknown", message: "Cannot read binary file: late-binary" } }) }), ) }) diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 4367d1b82e6..9958b0dfb4b 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -19,7 +19,7 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -import { executeTool, registerToolPlugin, settleTool, toolIdentity } from "./lib/tool" +import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" const globToolNode = makeLocationNode({ name: "test/glob-tool-plugin", @@ -83,15 +83,17 @@ describe("search tools", () => { ) yield* withTools(tmp.path, (registry) => Effect.gen(function* () { - const glob = yield* settleTool(registry, call("glob", { pattern: "*" })) - const grep = yield* settleTool(registry, call("grep", { pattern: "needle" })) + const glob = yield* executeTool(registry, call("glob", { pattern: "*" })) + const grep = yield* executeTool(registry, call("grep", { pattern: "needle" })) - expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT }) - expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT }) - expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }]) - expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }]) - expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) - expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`) + expect(glob.metadata).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT }) + expect(grep.metadata).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT }) + expect(glob.content).toHaveLength(1) + expect(grep.content).toHaveLength(1) + const globText = glob.content?.[0]?.type === "text" ? glob.content[0].text : "" + const grepText = grep.content?.[0]?.type === "text" ? grep.content[0].text : "" + expect(globText.split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) + expect(grepText).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`) }), ) }), @@ -110,7 +112,10 @@ describe("search tools", () => { registry, call(name, { path: "missing", pattern: name === "glob" ? "*" : "needle" }), ) - expect(result).toEqual({ type: "error", value: "Search path does not exist: missing" }) + expect(result).toEqual({ + status: "error", + error: { type: "tool.execution", message: "Search path does not exist: missing" }, + }) }), ), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 039680a8d18..69af78d5693 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -33,7 +33,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, settleTool, toolDefinitions, waitForTool } from "./lib/tool" +import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_shell_tool_test") const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") }) @@ -204,17 +204,19 @@ describe("ShellTool", () => { const definitions = yield* toolDefinitions(registry) const shell = definitions.find((tool) => tool.name === "shell") expect(shell).toBeDefined() - expect(shell?.outputSchema).not.toHaveProperty("properties.output") + // Code Mode receives the declared output schema, including the command output text. + expect(shell?.outputSchema).toHaveProperty("properties.output") expect( (yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map( (tool) => tool.name, ), ).not.toContain("shell") - const settled = yield* settleTool(registry, call({ command: helloCommand })) - expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false }) - expect(settled.output?.content[0]).toEqual({ type: "text", text: "hello" }) - expect(settled.output?.content[1]).toMatchObject({ + const settled = yield* executeTool(registry, call({ command: helloCommand })) + expect(settled.status).toBe("completed") + expect(settled.metadata).toMatchObject({ exit: 0, truncated: false }) + expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" }) + expect(settled.content?.[1]).toMatchObject({ type: "text", text: expect.stringContaining("Command exited with code 0."), }) @@ -233,11 +235,11 @@ describe("ShellTool", () => { reset() return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe( Effect.andThen( - withSession(tmp.path, (registry) => settleTool(registry, call({ command: cwdCommand, workdir: "src" }))), + withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))), ), Effect.andThen((settled) => Effect.sync(() => - expect(settled.output?.content[0]).toMatchObject({ + expect(settled.content?.[0]).toMatchObject({ type: "text", text: expect.stringContaining(realpathSync(path.join(tmp.path, "src"))), }), @@ -256,13 +258,13 @@ describe("ShellTool", () => { reset() return withSession(tmp.path, (registry) => Effect.gen(function* () { - const stderr = yield* settleTool(registry, call({ command: stderrCommand }, "call-stderr")) - expect(stderr.output?.structured).toMatchObject({ exit: 0, truncated: false }) - expect(stderr.output?.content[0]).toEqual({ type: "text", text: "stderr only" }) + const stderr = yield* executeTool(registry, call({ command: stderrCommand }, "call-stderr")) + expect(stderr.metadata).toMatchObject({ exit: 0, truncated: false }) + expect(stderr.content?.[0]).toEqual({ type: "text", text: "stderr only" }) - const mixed = yield* settleTool(registry, call({ command: mixedOutputCommand }, "call-mixed")) - expect(mixed.output?.structured).toMatchObject({ exit: 0, truncated: false }) - const output = mixed.output?.content[0]?.type === "text" ? mixed.output.content[0].text : "" + const mixed = yield* executeTool(registry, call({ command: mixedOutputCommand }, "call-mixed")) + expect(mixed.metadata).toMatchObject({ exit: 0, truncated: false }) + const output = mixed.content?.[0]?.type === "text" ? mixed.content[0].text : "" expect(output).toContain("stdout") expect(output).toContain("stderr") }), @@ -352,12 +354,12 @@ describe("ShellTool", () => { reset() denyAction = "external_directory" const target = path.join(outside.path, "secret.txt") - return withSession(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe( + return withSession(active.path, (registry) => executeTool(registry, call({ command: `cat ${target}` }))).pipe( Effect.andThen((settled) => Effect.sync(() => { expect(assertions.map((item) => item.action)).toEqual(["shell"]) - expect(settled.output?.structured).not.toHaveProperty("warnings") - expect(settled.output?.content[1]).toMatchObject({ + expect(settled.metadata).not.toHaveProperty("warnings") + expect(settled.content?.[1]).toMatchObject({ type: "text", text: expect.stringContaining("Warnings:"), }) @@ -378,13 +380,14 @@ describe("ShellTool", () => { (tmp) => { reset() return withSession(tmp.path, (registry) => - settleTool(registry, call({ command: bodyExitCommand }, "call-nonzero")), + executeTool(registry, call({ command: bodyExitCommand }, "call-nonzero")), ).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.output?.structured).toMatchObject({ exit: 7, truncated: false }) - expect(settled.output?.content[0]).toEqual({ type: "text", text: "body" }) - expect(settled.output?.content[1]).toMatchObject({ + expect(settled.status).toBe("completed") + expect(settled.metadata).toMatchObject({ exit: 7, truncated: false }) + expect(settled.content?.[0]).toEqual({ type: "text", text: "body" }) + expect(settled.content?.[1]).toMatchObject({ type: "text", text: expect.stringContaining("Command exited with code 7"), }) @@ -403,12 +406,12 @@ describe("ShellTool", () => { reset() const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024 return withSession(tmp.path, (registry) => - settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")), + executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")), ).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true }) - expect(settled.output?.content[0]).toMatchObject({ + expect(settled.metadata).toMatchObject({ exit: 0, truncated: true }) + expect(settled.content?.[0]).toMatchObject({ type: "text", text: expect.stringContaining("output truncated; full output saved to:"), }) @@ -421,7 +424,7 @@ describe("ShellTool", () => { ) it.live( - "reports bounded output progress for a running command", + "reports the shell ID for a running command", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -431,32 +434,21 @@ describe("ShellTool", () => { const releasePath = path.join(tmp.path, release) return withSession(tmp.path, (registry) => Effect.gen(function* () { - const observed = yield* Deferred.make() - yield* settleTool(registry, { + const observed = yield* Deferred.make() + yield* executeTool(registry, { ...call( { command: progressOverflowCommand(ShellTool.MAX_CAPTURE_BYTES + 1024, release) }, "call-progress", ), progress: (update) => Effect.gen(function* () { - if (update.structured.truncated !== true) return - const content = update.content[0] - if (content?.type !== "text") return - if (content.text.indexOf("\n\n[output truncated; full output saved to:") !== ShellTool.MAX_CAPTURE_BYTES) - return - yield* Deferred.succeed(observed, update) + if (typeof update.shellID !== "string") return + yield* Deferred.succeed(observed, update.shellID) yield* Effect.promise(() => fs.writeFile(releasePath, "")) }), }) - const progress = yield* Deferred.await(observed) - expect(progress.structured).toEqual({ truncated: true }) - const content = progress.content[0] - expect(content?.type).toBe("text") - if (content?.type !== "text") return - expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe( - ShellTool.MAX_CAPTURE_BYTES, - ) + expect(yield* Deferred.await(observed)).toMatch(/^sh_/) }).pipe(Effect.ensuring(Effect.promise(() => fs.writeFile(releasePath, "")).pipe(Effect.ignore))), ) }, @@ -466,7 +458,7 @@ describe("ShellTool", () => { ) it.live( - "does not repeat unchanged shell progress", + "does not repeat shell ID progress", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -475,16 +467,12 @@ describe("ShellTool", () => { return withSession(tmp.path, (registry) => Effect.gen(function* () { const updates: ToolRegistry.Progress[] = [] - yield* settleTool(registry, { + yield* executeTool(registry, { ...call({ command: steadyProgressCommand }, "call-steady-progress"), progress: (update) => Effect.sync(() => updates.push(update)), }) - expect(updates).toEqual([ - { - structured: { truncated: false }, - content: [{ type: "text", text: "steady" }], - }, - ]) + expect(updates).toHaveLength(1) + expect(updates[0]?.shellID).toMatch(/^sh_/) }), ) }, @@ -493,18 +481,18 @@ describe("ShellTool", () => { { timeout: 10_000 }, ) - it.live("returns a useful timeout settlement", () => + it.live("returns a useful timeout outcome", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => { reset() return withSession(tmp.path, (registry) => - settleTool(registry, call({ command: idleCommand, timeout: 50 })), + executeTool(registry, call({ command: idleCommand, timeout: 50 })), ).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.output?.structured).toMatchObject({ timeout: true, truncated: false }) - expect(settled.output?.content[1]).toMatchObject({ + expect(settled.metadata).toMatchObject({ timeout: true, truncated: false }) + expect(settled.content?.[1]).toMatchObject({ type: "text", text: expect.stringContaining("Command timed out"), }) @@ -529,10 +517,9 @@ describe("ShellTool", () => { Stream.runHead, Effect.forkScoped({ startImmediately: true }), ) - const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true })) - const structured = settled.output?.structured as Record | undefined - const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined - expect(settled.output?.structured).toMatchObject({ truncated: false }) + const settled = yield* executeTool(registry, call({ command: idleCommand, timeout: 50, background: true })) + const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined + expect(settled.metadata).toMatchObject({ truncated: false }) expect(shellID).toStartWith("sh_") const shell = yield* Shell.Service @@ -562,22 +549,22 @@ describe("ShellTool", () => { return withSession(tmp.path, (registry) => Effect.gen(function* () { const shell = yield* Shell.Service - const timed = yield* settleTool( + const timed = yield* executeTool( registry, call({ command: idleCommand, background: true }, "call-updated-timeout"), ) - const timedID = (timed.output?.structured as Record | undefined)?.shellID + const timedID = timed.metadata?.shellID expect(typeof timedID).toBe("string") if (typeof timedID !== "string") return const timedShellID = ShellSchema.ID.make(timedID) yield* shell.timeout(timedShellID, 50) expect((yield* shell.wait(timedShellID)).status).toBe("timeout") - const cleared = yield* settleTool( + const cleared = yield* executeTool( registry, call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"), ) - const clearedID = (cleared.output?.structured as Record | undefined)?.shellID + const clearedID = cleared.metadata?.shellID expect(typeof clearedID).toBe("string") if (typeof clearedID !== "string") return const clearedShellID = ShellSchema.ID.make(clearedID) @@ -601,7 +588,7 @@ describe("ShellTool", () => { Effect.gen(function* () { const jobs = yield* Job.Service const scope = yield* Scope.Scope - const waiting = yield* settleTool( + const waiting = yield* executeTool( registry, call({ command: idleCommand, timeout: 50 }, "call-background-signal"), ).pipe(Effect.forkIn(scope, { startImmediately: true })) @@ -616,14 +603,13 @@ describe("ShellTool", () => { }) expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }]) const settled = yield* Fiber.join(waiting) - const structured = settled.output?.structured as Record | undefined - const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined - expect(settled.output?.structured).toMatchObject({ truncated: false }) - expect(settled.output?.content[0]).toEqual({ + const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined + expect(settled.metadata).toMatchObject({ truncated: false }) + expect(settled.content?.[0]).toEqual({ type: "text", text: "The command was moved to the background.", }) - expect(settled.output?.content[1]).toMatchObject({ + expect(settled.content?.[1]).toMatchObject({ type: "text", text: expect.stringContaining("DO NOT sleep, poll"), }) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 72f5565deec..eab58f75d71 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -17,7 +17,7 @@ import { it } from "./lib/effect" import { imagePassthrough } from "./lib/image" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { FSUtil } from "@opencode-ai/util/fs-util" -import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const skillToolNode = makeLocationNode({ name: "test/skill-tool-plugin", @@ -108,23 +108,22 @@ describe("SkillTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } }, }), - ).toEqual({ - type: "text", - value: SkillTool.toModelOutput(info, [reference]), + ).toMatchObject({ + status: "completed", + content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }], }) expect(SkillTool.toModelOutput(info, [reference])).toContain(`Base directory for this skill: ${directory}`) expect( - yield* settleTool(registry, { + yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } }, }), ).toEqual({ - result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) }, - output: { - structured: { name: "Effect", directory }, - content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }], - }, + status: "completed", + output: { name: "Effect", directory, output: SkillTool.toModelOutput(info, [reference]) }, + content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }], + metadata: { name: "Effect", directory }, }) expect(assertions).toMatchObject([ { sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, @@ -136,7 +135,10 @@ describe("SkillTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } }, }), - ).toEqual({ type: "error", value: "Unable to load skill missing" }) + ).toEqual({ + status: "error", + error: { type: "tool.execution", message: "Unable to load skill missing" }, + }) deny = true expect( yield* executeTool(registry, { @@ -144,7 +146,10 @@ describe("SkillTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } }, }), - ).toEqual({ type: "error", value: "Unable to load skill effect" }) + ).toEqual({ + status: "error", + error: { type: "permission.rejected", message: "Permission denied: skill" }, + }) deny = false const flat = SkillV2.Info.make({ id: SkillV2.ID.make("public"), @@ -166,7 +171,10 @@ describe("SkillTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } }, }), - ).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) }) + ).toMatchObject({ + status: "completed", + content: [{ type: "text", text: SkillTool.toModelOutput(flat, []) }], + }) }).pipe(Effect.provide(skillToolLayer)) }), ), diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 8c70525a9ab..8ec8ecec162 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -28,7 +28,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -import { executeTool, settleTool, toolIdentity, waitForTool } from "./lib/tool" +import { executeTool, toolIdentity, waitForTool } from "./lib/tool" const childText = "child final response" const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") }) @@ -148,7 +148,7 @@ describe("SubagentTool", () => { const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) - expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name) + expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name) expect( yield* executeTool(registry, { sessionID: parent.id, @@ -160,7 +160,10 @@ describe("SubagentTool", () => { input: { agent: "primary", description: "primary", prompt: "should fail" }, }, }), - ).toEqual({ type: "error", value: "Agent primary cannot run as a subagent" }) + ).toEqual({ + status: "error", + error: { type: "tool.execution", message: "Agent primary cannot run as a subagent" }, + }) }), ), ), @@ -193,7 +196,13 @@ describe("SubagentTool", () => { input: { agent: "reviewer", description: "nested", prompt: "should fail" }, }, }), - ).toEqual({ type: "error", value: expect.stringContaining("Subagent depth limit reached (1)") }) + ).toEqual({ + status: "error", + error: { + type: "tool.execution", + message: expect.stringContaining("Subagent depth limit reached (1)"), + }, + }) expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(0) }), ), @@ -219,7 +228,7 @@ describe("SubagentTool", () => { const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) - const settled = yield* settleTool(registry, { + const settled = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { @@ -231,17 +240,15 @@ describe("SubagentTool", () => { }) expect(settled).toMatchObject({ - result: { type: "text", value: childText }, - output: { - structured: { status: "completed" }, - content: [{ type: "text", text: childText }], - }, + status: "completed", + metadata: { status: "completed" }, + content: [{ type: "text", text: childText }], }) - expect(settled.output?.structured).toEqual({ - sessionID: outputSessionID(settled.output?.structured), + expect(settled.metadata).toEqual({ + sessionID: outputSessionID(settled.metadata), status: "completed", }) - expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id) + expect((yield* sessions.get(outputSessionID(settled.metadata))).parentID).toBe(parent.id) }), ), ), @@ -263,7 +270,7 @@ describe("SubagentTool", () => { yield* waitForTool(registry, SubagentTool.name) const progress: ToolRegistry.Progress[] = [] - const settled = yield* settleTool(registry, { + const settled = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, progress: (update) => Effect.sync(() => progress.push(update)), @@ -276,15 +283,13 @@ describe("SubagentTool", () => { }) expect(settled).toMatchObject({ - result: { type: "text", value: childText }, - output: { - structured: { status: "completed" }, - content: [{ type: "text", text: childText }], - }, + status: "completed", + metadata: { status: "completed" }, + content: [{ type: "text", text: childText }], }) - const child = yield* sessions.get(outputSessionID(settled.output?.structured)) - expect(settled.output?.structured).toEqual({ sessionID: child.id, status: "completed" }) - expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" }) + const child = yield* sessions.get(outputSessionID(settled.metadata)) + expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" }) + expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" }) expect(child).toMatchObject({ parentID: parent.id, location: parent.location, @@ -295,7 +300,7 @@ describe("SubagentTool", () => { "You are a subagent spawned by another session.\nreview this", ) - const fallback = yield* settleTool(registry, { + const fallback = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { @@ -305,7 +310,7 @@ describe("SubagentTool", () => { input: { agent: "fallback", description: "fallback", prompt: "fallback" }, }, }) - const fallbackChild = yield* sessions.get(outputSessionID(fallback.output?.structured)) + const fallbackChild = yield* sessions.get(outputSessionID(fallback.metadata)) expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel }) }), ), @@ -338,7 +343,13 @@ describe("SubagentTool", () => { input: { agent: "reviewer", description: "fail review", prompt: "please fail" }, }, }), - ).toEqual({ type: "error", value: expect.stringContaining("No model is available for session") }) + ).toEqual({ + status: "error", + error: { + type: "tool.execution", + message: expect.stringContaining("No model is available for session"), + }, + }) }), ), ), @@ -366,7 +377,7 @@ describe("SubagentTool", () => { Effect.forkScoped({ startImmediately: true }), ) - const settled = yield* settleTool(registry, { + const settled = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { @@ -376,13 +387,12 @@ describe("SubagentTool", () => { input: { agent: "reviewer", description: "background review", prompt: "review", background: true }, }, }) - const childID = outputSessionID(settled.output?.structured) - expect(settled.output?.structured).toMatchObject({ + const childID = outputSessionID(settled.metadata) + expect(settled.metadata).toMatchObject({ status: "running", }) - expect(settled.output?.structured).toEqual({ sessionID: childID, status: "running" }) - expect(settled.result).toEqual({ type: "text", value: expect.stringContaining(`id: ${childID}`) }) - expect(settled.output?.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }]) + expect(settled.metadata).toEqual({ sessionID: childID, status: "running" }) + expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }]) const admission = Array.from(yield* Fiber.join(admitted))[0] expect(admission?.data.input.data.text).toContain(` { const url = "http://example.com/public" expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["webfetch"]) - expect(yield* settleTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({ - result: { type: "text", value: "hello" }, - output: { - structured: { contentType: "text/plain" }, - content: [{ type: "text", text: "hello" }], - }, + expect(yield* executeTool(registry, call({ url, format: "text", timeout: 4 }))).toEqual({ + status: "completed", + output: { url, contentType: "text/plain", format: "text", output: "hello" }, + content: [{ type: "text", text: "hello" }], + metadata: { contentType: "text/plain" }, }) expect(assertions).toMatchObject([ { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } }, @@ -113,9 +112,9 @@ describe("WebFetchTool registration", () => { const registry = yield* ToolRegistry.Service const url = "http://localhost/private" - expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({ - type: "text", - value: "hello", + expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "hello" }], }) expect(assertions).toMatchObject([ { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } }, @@ -141,9 +140,9 @@ describe("WebFetchTool registration", () => { const registry = yield* ToolRegistry.Service const url = new URL("/redirect", server.url).toString() - expect(yield* executeTool(registry, call({ url, format: "text" }))).toEqual({ - type: "text", - value: "redirected", + expect(yield* executeTool(registry, call({ url, format: "text" }))).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "redirected" }], }) expect(assertions).toMatchObject([ { sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } }, @@ -158,9 +157,10 @@ describe("WebFetchTool registration", () => { reset() const registry = yield* ToolRegistry.Service + // toSessionError unwraps the "Unable to fetch " ToolFailure to its cause message. expect(yield* executeTool(registry, call({ url: "file:///etc/passwd", format: "text" }))).toEqual({ - type: "error", - value: "Unable to fetch file:///etc/passwd", + status: "error", + error: { type: "unknown", message: "URL must use http:// or https://" }, }) expect(assertions).toEqual([]) expect(requests).toEqual([]) @@ -178,13 +178,13 @@ describe("WebFetchTool registration", () => { ) const registry = yield* ToolRegistry.Service - expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toEqual({ - type: "text", - value: "# Hello\n\nworld", + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "markdown" }))).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "# Hello\n\nworld" }], }) - expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({ - type: "text", - value: "Helloworld", + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "Helloworld" }], }) }), ) @@ -201,9 +201,9 @@ describe("WebFetchTool registration", () => { const registry = yield* ToolRegistry.Service const url = "https://1.1.1.1/deep-html" - expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toEqual({ - type: "error", - value: `Unable to fetch ${url}`, + expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({ + status: "error", + error: { type: "unknown" }, }) }), ) @@ -219,8 +219,11 @@ describe("WebFetchTool registration", () => { }), ) expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/declared", format: "text" }))).toEqual({ - type: "error", - value: "Unable to fetch https://1.1.1.1/declared", + status: "error", + error: { + type: "unknown", + message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`, + }, }) respond = () => @@ -228,26 +231,29 @@ describe("WebFetchTool registration", () => { new Response("x".repeat(WebFetchTool.MAX_RESPONSE_BYTES + 1), { headers: { "content-type": "text/plain" } }), ) expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/streamed", format: "text" }))).toEqual({ - type: "error", - value: "Unable to fetch https://1.1.1.1/streamed", + status: "error", + error: { + type: "unknown", + message: `Response too large (exceeds ${WebFetchTool.MAX_RESPONSE_BYTES} byte limit)`, + }, }) }), ) - it.effect("keeps images and files unsupported until typed settlement can carry attachments", () => + it.effect("keeps images and files unsupported until typed outcomes can carry attachments", () => Effect.gen(function* () { reset() const registry = yield* ToolRegistry.Service respond = () => Effect.succeed(new Response("png", { headers: { "content-type": "image/png" } })) expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/image", format: "html" }))).toEqual({ - type: "error", - value: "Unable to fetch https://1.1.1.1/image", + status: "error", + error: { type: "unknown", message: "Unsupported fetched image content type: image/png" }, }) respond = () => Effect.succeed(new Response("pdf", { headers: { "content-type": "application/pdf" } })) expect(yield* executeTool(registry, call({ url: "https://1.1.1.1/file", format: "html" }))).toEqual({ - type: "error", - value: "Unable to fetch https://1.1.1.1/file", + status: "error", + error: { type: "unknown", message: "Unsupported fetched file content type: application/pdf" }, }) }), ) @@ -264,9 +270,9 @@ describe("WebFetchTool registration", () => { ) const registry = yield* ToolRegistry.Service - expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toEqual({ - type: "text", - value: "ok", + expect(yield* executeTool(registry, call({ url: "https://1.1.1.1", format: "text" }))).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "ok" }], }) expect(requests).toHaveLength(2) expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0") @@ -285,7 +291,10 @@ describe("WebFetchTool registration", () => { ).pipe(Effect.forkChild) yield* TestClock.adjust(Duration.seconds(1)) - expect(yield* Fiber.join(fiber)).toEqual({ type: "error", value: "Unable to fetch https://1.1.1.1/slow" }) + expect(yield* Fiber.join(fiber)).toEqual({ + status: "error", + error: { type: "unknown", message: "Request timed out" }, + }) }), ) }) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 37d3960ab7c..7a8c2bb09c1 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -13,7 +13,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Image } from "@opencode-ai/core/image" import { testEffect } from "./lib/effect" import { imagePassthrough } from "./lib/image" -import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const webSearchToolNode = makeLocationNode({ name: "test/websearch-tool-plugin", @@ -172,7 +172,10 @@ describe("WebSearchTool registration", () => { }, }, }), - ).toEqual({ type: "text", value: "exa results" }) + ).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "exa results" }], + }) expect(assertions).toMatchObject([ { sessionID, @@ -221,7 +224,7 @@ describe("WebSearchTool registration", () => { config = { provider: "parallel", enableExa: false, enableParallel: false, parallelApiKey: "parallel-secret" } const registry = yield* ToolRegistry.Service - const settled = yield* settleTool(registry, { + const settled = yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-parallel", name: "websearch", input: { query: "effect layers" } }, @@ -242,11 +245,10 @@ describe("WebSearchTool registration", () => { }) expect(requests[0]?.body).not.toHaveProperty("params.arguments.model_name") expect(settled).toEqual({ - result: { type: "text", value: "parallel results" }, - output: { - structured: { provider: "parallel" }, - content: [{ type: "text", text: "parallel results" }], - }, + status: "completed", + output: { provider: "parallel", text: "parallel results" }, + content: [{ type: "text", text: "parallel results" }], + metadata: { provider: "parallel" }, }) expect(JSON.stringify(settled)).not.toContain("parallel-secret") }), @@ -260,7 +262,7 @@ describe("WebSearchTool registration", () => { config = { provider: "exa", enableExa: false, enableParallel: false, exaApiKey: "exa secret" } const registry = yield* ToolRegistry.Service - const settled = yield* settleTool(registry, { + const settled = yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-exa-key", name: "websearch", input: { query: "effect schema" } }, @@ -285,7 +287,10 @@ describe("WebSearchTool registration", () => { ...toolIdentity, call: { type: "tool-call", id: "call-empty", name: "websearch", input: { query: "nothing" } }, }), - ).toEqual({ type: "text", value: WebSearchTool.NO_RESULTS }) + ).toMatchObject({ + status: "completed", + content: [{ type: "text", text: WebSearchTool.NO_RESULTS }], + }) }), ) @@ -318,7 +323,12 @@ describe("WebSearchTool registration", () => { ...toolIdentity, call: { type: "tool-call", id: "call-large-response", name: "websearch", input: { query: "too much" } }, }), - ).toEqual({ type: "error", value: "Unable to search the web for too much" }) + // toSessionError unwraps the "Unable to search the web for " ToolFailure + // to its byte-limit cause message. + ).toEqual({ + status: "error", + error: { type: "unknown", message: expect.stringContaining("response exceeded") }, + }) expect(chunksRead).toBeLessThan(10) expect(cancelled).toBe(true) }), diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 67a001e0eb0..3cd3dcb4b61 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -18,7 +18,7 @@ import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { testEffect } from "./lib/effect" -import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" +import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" const writeToolNode = makeLocationNode({ name: "test/write-tool-plugin", @@ -119,18 +119,16 @@ describe("WriteTool", () => { return withTool(tmp.path, (registry) => Effect.gen(function* () { expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"]) - const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" })) + const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" })) expect(settled).toEqual({ - result: { type: "text", value: "Created file successfully: src/new.txt" }, + status: "completed", output: { - structured: { - operation: "write", - target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"), - resource: "src/new.txt", - existed: false, - }, - content: [{ type: "text", text: "Created file successfully: src/new.txt" }], + operation: "write", + target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"), + resource: "src/new.txt", + existed: false, }, + content: [{ type: "text", text: "Created file successfully: src/new.txt" }], }) expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe( "created", @@ -151,12 +149,14 @@ describe("WriteTool", () => { reset() return Effect.promise(() => fs.writeFile(path.join(tmp.path, "existing.txt"), "before")).pipe( Effect.andThen( - withTool(tmp.path, (registry) => settleTool(registry, call({ path: "existing.txt", content: "after" }))), + withTool(tmp.path, (registry) => executeTool(registry, call({ path: "existing.txt", content: "after" }))), ), Effect.andThen((settled) => Effect.gen(function* () { - expect(settled.result).toEqual({ type: "text", value: "Wrote file successfully: existing.txt" }) - expect(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true }) + expect(settled.status).toBe("completed") + if (settled.status !== "completed") return + expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }]) + expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true }) expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe( "after", ) @@ -182,8 +182,8 @@ describe("WriteTool", () => { Effect.andThen( withTool(tmp.path, (registry) => Effect.gen(function* () { - yield* settleTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved")) - yield* settleTool( + yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved")) + yield* executeTool( registry, call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"), ) @@ -208,7 +208,10 @@ describe("WriteTool", () => { return withTool(tmp.path, (registry) => executeTool(registry, call({ path: target, content: "inside" }))).pipe( Effect.andThen((result) => Effect.gen(function* () { - expect(result).toEqual({ type: "text", value: "Created file successfully: absolute.txt" }) + expect(result).toMatchObject({ + status: "completed", + content: [{ type: "text", text: "Created file successfully: absolute.txt" }], + }) expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("inside") }), @@ -236,7 +239,7 @@ describe("WriteTool", () => { ), Effect.andThen((result) => Effect.sync(() => { - expect(result.type).toBe("text") + expect(result.status).toBe("completed") expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(assertions[0]?.resources).toEqual(["link.txt"]) }), @@ -259,7 +262,7 @@ describe("WriteTool", () => { reset() const target = path.join(outside.path, "external.txt") return withTool(active.path, (registry) => - settleTool(registry, call({ path: target, content: "external" })), + executeTool(registry, call({ path: target, content: "external" })), ).pipe( Effect.andThen((settled) => Effect.gen(function* () { @@ -271,10 +274,13 @@ describe("WriteTool", () => { ], }) expect(assertions[1]).toMatchObject({ resources: [canonicalTarget.replaceAll("\\", "/")], save: ["*"] }) - expect(settled.output?.structured).toMatchObject({ - target: canonicalTarget, - resource: canonicalTarget.replaceAll("\\", "/"), - existed: false, + expect(settled).toMatchObject({ + status: "completed", + output: { + target: canonicalTarget, + resource: canonicalTarget.replaceAll("\\", "/"), + existed: false, + }, }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("external") expect(writes).toEqual([canonicalTarget]) @@ -336,8 +342,8 @@ describe("WriteTool", () => { executeTool(registry, call({ path: external, content: "blocked" })), ), ).toEqual({ - type: "error", - value: `Unable to write ${external}`, + status: "error", + error: { type: "permission.rejected", message: "Permission denied: external_directory" }, }) expect(assertions.map((input) => input.action)).toEqual(["external_directory"]) expect(writes).toEqual([]) @@ -349,8 +355,8 @@ describe("WriteTool", () => { executeTool(registry, call({ path: "denied.txt", content: "blocked" })), ), ).toEqual({ - type: "error", - value: "Unable to write denied.txt", + status: "error", + error: { type: "permission.rejected", message: "Permission denied: edit" }, }) expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(writes).toEqual([]) diff --git a/packages/docs/build/plugins.mdx b/packages/docs/build/plugins.mdx index 01a8b5746b9..e358a3ccae9 100644 --- a/packages/docs/build/plugins.mdx +++ b/packages/docs/build/plugins.mdx @@ -248,7 +248,7 @@ mutable fields: | `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` | | `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | | `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes | -| `ctx.tool.hook("execute.after", callback)` | `result`, `output`, and `outputPaths`, after execution settles | +| `ctx.tool.hook("execute.after", callback)` | Terminal `content`, `metadata`, and `outputPaths`; `error` on failure | For example, remove a tool from selected model requests and normalize another tool's input: @@ -278,52 +278,66 @@ handle expected errors inside the callback. ### Add a tool -Pass a tool declaration to `tools.add`. Define its input with JSON Schema and -use an async executor: +Create an executable tool with `Tool.make`, then register it with a name +and registration options. Define its input with JSON Schema and use an async +executor: ```js title=".opencode/plugins/greeting.js" import { Plugin } from "@opencode-ai/plugin/v2" +import { Tool } from "@opencode-ai/plugin/v2/tool" export default Plugin.define({ id: "acme.greeting", setup: async (ctx) => { await ctx.tool.transform((tools) => { - tools.add({ - name: "greeting", - description: "Create a greeting", - jsonSchema: { - type: "object", - properties: { - name: { type: "string" }, + tools.add( + "greeting", + Tool.make({ + description: "Create a greeting", + input: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + additionalProperties: false, }, - required: ["name"], - additionalProperties: false, - }, - execute: async ({ name }) => { - const text = `Hello, ${name}!` - return { - structured: { greeting: text }, - content: [{ type: "text", text }], - } - }, - }) + output: { + type: "object", + properties: { greeting: { type: "string" } }, + required: ["greeting"], + additionalProperties: false, + }, + execute: async ({ name }) => { + const text = `Hello, ${name}!` + return { + output: { greeting: text }, + content: text, + } + }, + }), + ) }) }, }) ``` -Unsupported characters in tool and group names are normalized to underscores. -The resulting exposed key must begin with a letter and contain at most 64 -letters, digits, underscores, or hyphens. Set `options` on the declaration to -configure registration with `{ group, codemode }`: +Unsupported characters in tool names are normalized to underscores. Namespace +segments must begin with a letter, contain at most 64 letters, digits, +underscores, or hyphens, and are joined with dots. Pass the optional third +argument to `tools.add` to configure the registration with +`{ namespace, codemode }`: -- `group` prefixes and groups the exposed tool name. +- `namespace` prefixes and groups the exposed tool name. - `codemode` defaults to `true` and makes the tool available through the `execute` CodeMode tool. Set `codemode: false` to expose it directly to the provider. The executor receives a second context argument containing `sessionID`, -`agent`, `assistantMessageID`, and `toolCallID`. +`agent`, `messageID`, `callID`, and `progress`. A tool with `output` +must return `output`; Effect and Standard Schema codecs validate it, while raw +JSON Schema definitions enforce JSON compatibility only. A tool +without `output` returns model-visible `content` instead. ### Add a command @@ -426,6 +440,7 @@ fibers, and registrations are released when the plugin reloads or unloads. OpenCode does not expose its private Core services to the plugin; use the capabilities on `ctx`. -Typed tools can use `Schema` from `effect` and the contracts exported from -`@opencode-ai/plugin/v2/effect/tool`. Their executors return an Effect and may -fail with the typed tool failure channel. +Typed tools can use `Schema` from `effect` and `Tool.make` from +`@opencode-ai/plugin/v2/effect/tool`. Effect and Promise plugins use the same +`tools.add(name, tool, options?)` registration shape. Effect executors +return an Effect and may fail with the typed tool failure channel. diff --git a/packages/plugin/src/v2/effect/internal/tool.ts b/packages/plugin/src/v2/effect/internal/tool.ts new file mode 100644 index 00000000000..abc92c6a7d2 --- /dev/null +++ b/packages/plugin/src/v2/effect/internal/tool.ts @@ -0,0 +1,315 @@ +import { Agent } from "@opencode-ai/schema/agent" +import { LLM } from "@opencode-ai/schema/llm" +import { Session } from "@opencode-ai/schema/session" +import { SessionError } from "@opencode-ai/schema/session-error" +import { SessionMessage } from "@opencode-ai/schema/session-message" +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec" +import { Effect, JsonSchema, Schema } from "effect" +import type { Hooks, Transform } from "../registration.js" + +// Tools + +/** A JSON-compatible value. Tool metadata and encoded outputs must be JSON. */ +export type JsonValue = typeof Schema.Json.Type + +/** Compact JSON metadata for tool-specific UI and client behavior. */ +export type Metadata = Readonly> + +export interface Context { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly messageID: SessionMessage.ID + readonly callID: string + readonly progress: (update: Progress) => Effect.Effect +} + +/** Live replacement metadata for a running tool. */ +export type Progress = Metadata + +export type StandardSchemaType = StandardSchemaV1 & + StandardJSONSchemaV1 +export type SchemaType = Schema.Codec | StandardSchemaType | JsonSchema.JsonSchema +type IsAny = 0 extends 1 & A ? true : false +export type InputValue = + IsAny extends true + ? any + : S extends Schema.Codec + ? A + : S extends StandardSchemaV1 + ? A + : unknown +export type OutputValue = + IsAny extends true + ? any + : S extends Schema.Codec + ? A + : S extends StandardSchemaV1 + ? A + : unknown +export type EncodedValue = + IsAny extends true + ? any + : S extends Schema.Codec + ? A + : S extends StandardSchemaV1 + ? A + : unknown + +type ToolDefinition = { + readonly name: string + readonly description: string + readonly inputSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema +} + +export class Failure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { + message: Schema.String, + error: Schema.optional(Schema.Defect()), +}) {} + +export class RegistrationError extends Schema.TaggedErrorClass()("Tool.RegistrationError", { + name: Schema.String, + message: Schema.String, +}) {} + +export type Content = + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } + +/** Model-facing tool content: plain text or non-empty rich content. */ +export type ModelOutput = string | readonly [Content, ...Content[]] + +type BaseTool> = { + readonly description: string + readonly input: Input +} + +export type Response> = { + readonly output: OutputValue + readonly content?: ModelOutput + readonly metadata?: Metadata +} + +export type ContentResponse = { + readonly content: ModelOutput + readonly metadata?: Metadata +} + +export type Tool< + Input extends SchemaType, + Output extends SchemaType | undefined = undefined, +> = BaseTool & + (Output extends SchemaType + ? { + readonly output: Output + readonly execute: (input: InputValue, context: Context) => Effect.Effect, Failure> + } + : { + readonly output?: undefined + readonly execute: (input: InputValue, context: Context) => Effect.Effect + }) + +export type Any = BaseTool & { + readonly output?: SchemaType + readonly execute: (input: any, context: Context) => Effect.Effect | ContentResponse, Failure> +} + +export function make, Output extends SchemaType>( + config: Tool, +): Tool +export function make>(config: Tool): Tool +export function make(config: Any): Any +export function make(config: Any): Any { + return config +} + +// Registration + +export interface RegisterOptions { + readonly namespace?: string + /** Defaults to true. False exposes the tool directly to the provider. */ + readonly codemode?: boolean + /** Permission action used for whole-tool visibility filtering. */ + readonly permission?: string +} + +export interface Registration { + readonly tool: Any + readonly name: string + readonly namespace?: string + readonly permission: string +} + +export const validateName = (name: string) => + /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name) + ? Effect.void + : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) + +export const registrationEntries = ( + tools: Readonly>, + options?: RegisterOptions, +): Array => + Object.entries(tools).map(([name, tool]) => { + const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_") + const key = + options?.namespace === undefined ? normalized : `${options.namespace.replaceAll(".", "_")}_${normalized}` + return { + key, + name: normalized, + namespace: options?.namespace, + tool, + permission: options?.permission ?? key, + } + }) + +export const validateNamespace = (namespace: string) => + namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment)) + ? Effect.void + : Effect.fail( + new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }), + ) + +export const toLLMDefinition = (name: string, tool: Any): ToolDefinition => ({ + name, + description: tool.description, + inputSchema: inputJsonSchema(tool.input), + ...(tool.output === undefined ? {} : { outputSchema: outputJsonSchema(tool.output) }), +}) + +// Schema interpretation + +export function decodeInput(schema: SchemaType, value: unknown): Effect.Effect { + if (Schema.isSchema(schema)) + return Schema.decodeUnknownEffect(schema)(value).pipe( + Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })), + ) + if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input") + return Effect.succeed(value) +} + +export function encodeOutput(schema: SchemaType, value: unknown): Effect.Effect { + if (Schema.isSchema(schema)) + return Schema.encodeEffect(schema)(value).pipe( + Effect.mapError( + (error) => new Failure({ message: `Tool returned an invalid value for its output schema: ${error.message}` }), + ), + ) + if (isStandardSchema(schema)) + return validateStandard(schema, value, "Tool returned an invalid value for its output schema") + return Schema.decodeUnknownEffect(Schema.Json)(value).pipe( + Effect.mapError( + (error) => new Failure({ message: `Tool returned a non-JSON value for its output schema: ${error.message}` }), + ), + ) +} + +function isStandardSchema(schema: SchemaType): schema is StandardSchemaType { + return "~standard" in schema +} + +function validateStandard(schema: StandardSchemaType, value: unknown, prefix: string): Effect.Effect { + return Effect.gen(function* () { + const pending = yield* Effect.try({ + try: () => schema["~standard"].validate(value), + catch: (error) => standardFailure(prefix, error), + }) + const result = + pending instanceof Promise + ? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) }) + : pending + if (result.issues) + return yield* Effect.fail( + new Failure({ message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}` }), + ) + return result.value + }) +} + +function standardFailure(prefix: string, error: unknown) { + return new Failure({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` }) +} + +function inputJsonSchema(schema: SchemaType): JsonSchema.JsonSchema { + if (isStandardSchema(schema)) + return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema + return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema) +} + +function outputJsonSchema(schema: SchemaType): JsonSchema.JsonSchema { + if (isStandardSchema(schema)) + return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema + return Schema.isSchema(schema) ? toJsonSchema(schema) : (schema as JsonSchema.JsonSchema) +} + +function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema { + const document = Schema.toJsonSchemaDocument(schema) + if (Object.keys(document.definitions).length === 0) return document.schema + return { ...document.schema, $defs: document.definitions } +} + +// Plugin events + +export interface ToolExecuteBeforeEvent { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly messageID: SessionMessage.ID + readonly callID: string + input: unknown +} + +type ToolHookBase = { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly messageID: SessionMessage.ID + readonly callID: string + readonly input: unknown +} + +export const ExecuteAfterOutcome = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("completed"), + content: Schema.NonEmptyArray(LLM.ToolContent), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Json)), + outputPaths: Schema.optional(Schema.Array(Schema.String)), + }), + Schema.Struct({ + status: Schema.Literal("error"), + error: SessionError.Error, + content: Schema.optional(Schema.NonEmptyArray(LLM.ToolContent)), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Json)), + outputPaths: Schema.optional(Schema.Array(Schema.String)), + }), +]).pipe(Schema.toTaggedUnion("status")) + +type Mutable = { -readonly [K in keyof A]: A[K] } +type HookOutcome = Omit, "status"> & Pick + +/** The bounded terminal outcome exposed to tool hooks. */ +export type Outcome = typeof ExecuteAfterOutcome.Type extends infer A + ? A extends { readonly status: string } + ? HookOutcome + : never + : never + +/** + * The canonical execution outcome as seen by `execute.after` hooks. Hooks + * observe bounded model content, optional metadata, and managed output paths; + * they never observe the raw domain output. + */ +export type ToolExecuteAfterEvent = ToolHookBase & Outcome + +export interface ToolDraft { + add(name: string, tool: Any, options?: RegisterOptions): void +} + +export interface ToolHooks { + readonly "execute.before": ToolExecuteBeforeEvent + readonly "execute.after": ToolExecuteAfterEvent +} + +export interface ToolDomain { + readonly transform: Transform + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index 879e5badef2..cbc634f8e5b 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -1,314 +1,2 @@ -export * as Tool from "./tool.js" - -import { Agent } from "@opencode-ai/schema/agent" -import type { LLM } from "@opencode-ai/schema/llm" -import { Session } from "@opencode-ai/schema/session" -import { SessionMessage } from "@opencode-ai/schema/session-message" -import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec" -import { Effect, JsonSchema, Schema } from "effect" -import type { Hooks, Transform } from "./registration.js" - -export interface Context { - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly messageID: SessionMessage.ID - readonly callID: string - readonly progress: (update: Progress) => Effect.Effect -} - -export interface Progress { - readonly structured: Readonly> - readonly content?: ReadonlyArray -} - -export type StandardSchemaType = StandardSchemaV1 & - StandardJSONSchemaV1 -export type SchemaType = Schema.Codec | StandardSchemaType -type IsAny = 0 extends 1 & A ? true : false -export type InputValue = - IsAny extends true - ? any - : S extends Schema.Codec - ? A - : S extends StandardSchemaV1 - ? A - : never -export type OutputValue = - IsAny extends true - ? any - : S extends Schema.Codec - ? A - : S extends StandardSchemaV1 - ? A - : never -export type EncodedValue = - IsAny extends true - ? any - : S extends Schema.Codec - ? A - : S extends StandardSchemaV1 - ? A - : never - -type ToolDefinition = { - readonly name: string - readonly description: string - readonly inputSchema: JsonSchema.JsonSchema - readonly outputSchema?: JsonSchema.JsonSchema -} - -type ToolCall = { - readonly input: unknown - readonly [key: string]: unknown -} - -type ToolResultValue = - | { readonly type: "json"; readonly value: unknown } - | { readonly type: "text"; readonly value: unknown } - | { readonly type: "error"; readonly value: unknown } - | { readonly type: "content"; readonly value: ReadonlyArray } - -type ToolOutput = { - readonly structured: unknown - readonly content: ReadonlyArray -} - -export class Failure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { - message: Schema.String, - error: Schema.optional(Schema.Defect()), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}) {} - -export class RegistrationError extends Schema.TaggedErrorClass()("Tool.RegistrationError", { - name: Schema.String, - message: Schema.String, -}) {} - -export type Content = - | { readonly type: "text"; readonly text: string } - | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } - -export type Definition< - Input extends SchemaType, - Structured extends SchemaType, - Output extends SchemaType = any, -> = { - readonly description: string - readonly input: Input - readonly output: Output - readonly structured?: Structured - readonly permission?: string - readonly toStructuredOutput?: (input: { - readonly input: InputValue - readonly output: EncodedValue - }) => OutputValue - readonly execute: (input: InputValue, context: Context) => Effect.Effect, Failure> - readonly toModelOutput?: (input: { - readonly input: InputValue - readonly output: EncodedValue - }) => ReadonlyArray -} - -export type DynamicOutput = { - readonly structured: unknown - readonly content: ReadonlyArray -} - -/** - * Config for a tool whose input shape is a raw JSON Schema not known at compile - * time (MCP servers, plugin manifests). Input is passed through as `unknown`; - * `execute` returns the already-projected structured value and model content. - */ -export type DynamicDefinition = { - readonly description: string - readonly jsonSchema: JsonSchema.JsonSchema - readonly outputSchema?: JsonSchema.JsonSchema - readonly permission?: string - readonly execute: (input: unknown, context: Context) => Effect.Effect -} - -export type AnyTool = Definition | DynamicDefinition - -export function make< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, ->(config: Definition): Definition -export function make(config: DynamicDefinition): DynamicDefinition -export function make(config: AnyTool): AnyTool -export function make(config: AnyTool): AnyTool { - return config -} - -function toModelContent(part: Content) { - if (part.type === "text") return { type: "text" as const, text: part.text } - return { type: "file" as const, uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name } -} - -export const validateName = (name: string) => - /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name) - ? Effect.void - : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) - -export const registrationEntries = (tools: Readonly>, namespace?: string) => - Object.entries(tools).map(([name, tool]) => { - const normalized = name.replace(/[^a-zA-Z0-9_-]/g, "_") - return { - key: namespace === undefined ? normalized : `${namespace.replaceAll(".", "_")}_${normalized}`, - name: normalized, - namespace, - tool, - } - }) - -export const validateNamespace = (namespace: string) => - namespace.split(".").every((segment) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(segment)) - ? Effect.void - : Effect.fail( - new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` }), - ) - -export const withPermission = ( - tool: T, - permission: string, -): Omit & { - readonly permission: string -} => ({ ...tool, permission }) - -export const permission = (tool: AnyTool, name: string) => tool.permission ?? name - -export const definition = (name: string, tool: AnyTool): ToolDefinition => - "jsonSchema" in tool - ? { - name, - description: tool.description, - inputSchema: tool.jsonSchema, - outputSchema: tool.outputSchema, - } - : { - name, - description: tool.description, - inputSchema: inputJsonSchema(tool.input), - outputSchema: outputJsonSchema(tool.structured ?? tool.output), - } - -export const settle = (tool: AnyTool, call: ToolCall, context: Context): Effect.Effect => - Effect.gen(function* () { - if ("jsonSchema" in tool) { - const output = yield* tool.execute(call.input, context) - return { structured: output.structured, content: output.content.map(toModelContent) } - } - - const input = yield* decodeInput(tool.input, call.input) - const value = yield* tool.execute(input, context) - const output = yield* encodeOutput(tool.output, value) - const structured = - tool.structured && tool.toStructuredOutput - ? yield* encodeOutput(tool.structured, tool.toStructuredOutput({ input, output })) - : output - return { - structured, - content: - tool.toModelOutput?.({ input, output }).map(toModelContent) ?? - (typeof output === "string" ? [{ type: "text" as const, text: output }] : []), - } - }) - -function decodeInput(schema: SchemaType, value: unknown): Effect.Effect { - if (Schema.isSchema(schema)) - return Schema.decodeUnknownEffect(schema)(value).pipe( - Effect.mapError((error) => new Failure({ message: `Invalid tool input: ${error.message}` })), - ) - return validateStandard(schema, value, "Invalid tool input") -} - -function encodeOutput(schema: SchemaType, value: unknown): Effect.Effect { - if (Schema.isSchema(schema)) - return Schema.encodeEffect(schema)(value).pipe( - Effect.mapError( - (error) => new Failure({ message: `Tool returned an invalid value for its output schema: ${error.message}` }), - ), - ) - return validateStandard(schema, value, "Tool returned an invalid value for its output schema") -} - -function validateStandard(schema: StandardSchemaType, value: unknown, prefix: string): Effect.Effect { - return Effect.gen(function* () { - const pending = yield* Effect.try({ - try: () => schema["~standard"].validate(value), - catch: (error) => standardFailure(prefix, error), - }) - const result = - pending instanceof Promise - ? yield* Effect.tryPromise({ try: () => pending, catch: (error) => standardFailure(prefix, error) }) - : pending - if (result.issues) - return yield* Effect.fail( - new Failure({ message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}` }), - ) - return result.value - }) -} - -function standardFailure(prefix: string, error: unknown) { - return new Failure({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` }) -} - -function inputJsonSchema(schema: SchemaType): JsonSchema.JsonSchema { - if (!Schema.isSchema(schema)) - return schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }) as JsonSchema.JsonSchema - return toJsonSchema(schema) -} - -function outputJsonSchema(schema: SchemaType): JsonSchema.JsonSchema { - if (!Schema.isSchema(schema)) - return schema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) as JsonSchema.JsonSchema - return toJsonSchema(schema) -} - -function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema { - const document = Schema.toJsonSchemaDocument(schema) - if (Object.keys(document.definitions).length === 0) return document.schema - return { ...document.schema, $defs: document.definitions } -} - -export interface ToolExecuteBeforeEvent { - readonly tool: string - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly messageID: SessionMessage.ID - readonly callID: string - input: unknown -} - -export interface ToolExecuteAfterEvent { - readonly tool: string - readonly sessionID: Session.ID - readonly agent: Agent.ID - readonly messageID: SessionMessage.ID - readonly callID: string - readonly input: unknown - result: ToolResultValue - output?: ToolOutput - outputPaths?: ReadonlyArray -} - -export interface RegisterOptions { - readonly namespace?: string - /** Defaults to true. False exposes the tool directly to the provider. */ - readonly codemode?: boolean -} - -export interface ToolDraft { - add(name: string, tool: AnyTool, options?: RegisterOptions): void -} - -export interface ToolHooks { - readonly "execute.before": ToolExecuteBeforeEvent - readonly "execute.after": ToolExecuteAfterEvent -} - -export interface ToolDomain { - readonly transform: Transform - readonly hook: Hooks -} +export * as Tool from "./internal/tool.js" +export * from "./internal/tool.js" diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md index e23a231e4fa..c7ffbfd251c 100644 --- a/packages/plugin/src/v2/promise/README.md +++ b/packages/plugin/src/v2/promise/README.md @@ -94,19 +94,23 @@ await ctx.session.hook("context", (event) => { }) ``` -Promise tools use plain object declarations with async executors: +Promise tools use executable tool values with async executors. Registration +supplies the tool's name and options separately: ```ts import { Schema } from "effect" +import { Tool } from "@opencode-ai/plugin/v2/tool" await ctx.tool.transform((tools) => { - tools.add({ - name: "echo", - description: "Echo text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: async ({ text }) => ({ text }), - }) + tools.add( + "echo", + Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: async ({ text }) => ({ output: { text }, content: text }), + }), + ) }) ``` diff --git a/packages/plugin/src/v2/promise/internal/tool.ts b/packages/plugin/src/v2/promise/internal/tool.ts new file mode 100644 index 00000000000..3d5def019ba --- /dev/null +++ b/packages/plugin/src/v2/promise/internal/tool.ts @@ -0,0 +1,64 @@ +import type { Hooks, Transform } from "../registration.js" + +export type Context = Omit & { + readonly progress: (update: import("../../effect/internal/tool.js").Progress) => Promise +} +export type SchemaType = import("../../effect/internal/tool.js").SchemaType +export type Content = import("../../effect/internal/tool.js").Content +export type Metadata = import("../../effect/internal/tool.js").Metadata +export type ModelOutput = import("../../effect/internal/tool.js").ModelOutput + +export type Tool, Output extends SchemaType | undefined = undefined> = Omit< + import("../../effect/internal/tool.js").Tool, + "execute" +> & { + readonly execute: ( + input: import("../../effect/internal/tool.js").InputValue, + context: Context, + ) => Promise< + Output extends SchemaType + ? import("../../effect/internal/tool.js").Response + : import("../../effect/internal/tool.js").ContentResponse + > +} + +export type Any = Omit & { + readonly execute: ( + input: any, + context: Context, + ) => Promise< + import("../../effect/internal/tool.js").Response | import("../../effect/internal/tool.js").ContentResponse + > +} + +export function make, Output extends SchemaType>( + tool: Tool, +): Tool +export function make>(tool: Tool): Tool +export function make(tool: Any): Any +export function make(tool: Any): Any { + return tool +} + +export type ToolExecuteBeforeEvent = import("../../effect/internal/tool.js").ToolExecuteBeforeEvent +export type ToolExecuteAfterEvent = import("../../effect/internal/tool.js").ToolExecuteAfterEvent +export type RegisterOptions = import("../../effect/internal/tool.js").RegisterOptions + +export interface ToolDraft { + add, Output extends SchemaType>( + name: string, + tool: Tool, + options?: RegisterOptions, + ): void + add>(name: string, tool: Tool, options?: RegisterOptions): void +} + +export interface ToolHooks { + readonly "execute.before": ToolExecuteBeforeEvent + readonly "execute.after": ToolExecuteAfterEvent +} + +export interface ToolDomain { + readonly transform: Transform + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/promise/tool.ts b/packages/plugin/src/v2/promise/tool.ts index 91b9b2c1e28..cbc634f8e5b 100644 --- a/packages/plugin/src/v2/promise/tool.ts +++ b/packages/plugin/src/v2/promise/tool.ts @@ -1,48 +1,2 @@ -import type { Tool } from "../effect/tool.js" -import type { Hooks, Transform } from "./registration.js" - -export type Context = Omit & { - readonly progress: (update: Tool.Progress) => Promise -} -export type SchemaType = Tool.SchemaType -export type Content = Tool.Content -export type DynamicOutput = Tool.DynamicOutput - -export type Definition< - Input extends SchemaType, - Output extends SchemaType, - Structured extends SchemaType = Output, -> = Omit, "execute" | "permission"> & { - readonly name: string - readonly options?: RegisterOptions - readonly execute: (input: Tool.InputValue, context: Context) => Promise> -} - -export type DynamicDefinition = Omit & { - readonly name: string - readonly options?: RegisterOptions - readonly execute: (input: unknown, context: Context) => Promise -} - -export type AnyTool = Definition | DynamicDefinition - -export type ToolExecuteBeforeEvent = Tool.ToolExecuteBeforeEvent -export type ToolExecuteAfterEvent = Tool.ToolExecuteAfterEvent -export type RegisterOptions = Tool.RegisterOptions - -export interface ToolDraft { - add, Output extends SchemaType, Structured extends SchemaType = Output>( - tool: Definition, - ): void - add(tool: DynamicDefinition): void -} - -export interface ToolHooks { - readonly "execute.before": ToolExecuteBeforeEvent - readonly "execute.after": ToolExecuteAfterEvent -} - -export interface ToolDomain { - readonly transform: Transform - readonly hook: Hooks -} +export * as Tool from "./internal/tool.js" +export * from "./internal/tool.js" diff --git a/packages/plugin/test/tool.test.ts b/packages/plugin/test/tool.test.ts index c22ddbec9a7..19017cab20e 100644 --- a/packages/plugin/test/tool.test.ts +++ b/packages/plugin/test/tool.test.ts @@ -1,29 +1,18 @@ import { expect, test } from "bun:test" -import { Agent } from "@opencode-ai/schema/agent" -import { Session } from "@opencode-ai/schema/session" -import { SessionMessage } from "@opencode-ai/schema/session-message" import { Effect, Schema } from "effect" import * as Tool from "../src/v2/effect/tool" -const context = { - sessionID: Session.ID.make("ses_test"), - agent: Agent.ID.make("build"), - messageID: SessionMessage.ID.make("msg_test"), - callID: "call_test", - progress: () => Effect.void, -} satisfies Tool.Context - test("tools remain valid across separate module instances", async () => { const ForeignTool = await import(`${new URL("../src/v2/effect/tool.ts", import.meta.url).href}?foreign`) const config = { description: "Foreign tool", input: Schema.Struct({ value: Schema.String }), output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), + execute: () => Effect.succeed({ output: { ok: true } }), } const tool = ForeignTool.make(config) - expect(Tool.definition("foreign", tool)).toEqual({ + expect(Tool.toLLMDefinition("foreign", tool)).toEqual({ name: "foreign", description: "Foreign tool", inputSchema: { @@ -39,10 +28,7 @@ test("tools remain valid across separate module instances", async () => { additionalProperties: false, }, }) - expect(await Effect.runPromise(Tool.settle(tool, { input: { value: "input" } }, context))).toEqual({ - structured: { ok: true }, - content: [], - }) + expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: "input" }))).toEqual({ value: "input" }) }) test("portable schemas validate and describe typed tools", async () => { @@ -77,19 +63,18 @@ test("portable schemas validate and describe typed tools", async () => { description: "Portable tool", input, output, - execute: ({ count }) => Effect.succeed(count + 1), + execute: ({ count }) => Effect.succeed({ output: count + 1 }), }) - expect(Tool.definition("portable", tool)).toEqual({ + expect(Tool.toLLMDefinition("portable", tool)).toEqual({ name: "portable", description: "Portable tool", inputSchema: { type: "object", properties: { count: { type: "string" } } }, outputSchema: { type: "string" }, }) - expect(await Effect.runPromise(Tool.settle(tool, { input: { count: "41" } }, context))).toEqual({ - structured: "42", - content: [{ type: "text", text: "42" }], - }) + const decoded = await Effect.runPromise(Tool.decodeInput(tool.input, { count: "41" })) + expect(decoded).toEqual({ count: 41 }) + expect(await Effect.runPromise(Tool.encodeOutput(tool.output, 42))).toBe("42") }) test("portable schema failures become tool failures", async () => { @@ -104,29 +89,39 @@ test("portable schema failures become tool failures", async () => { }, }, } - const tool = Tool.make({ - description: "Failing tool", - input, - output: input, - execute: Effect.succeed, - }) - const error = await Effect.runPromiseExit(Tool.settle(tool, { input: 1 }, context)) + const error = await Effect.runPromiseExit(Tool.decodeInput(input, 1)) expect(error.toString()).toContain("Invalid tool input: expected a string") }) -test("two-parameter Definition annotations retain their original meaning", () => { +test("canonical results carry metadata with typed output", async () => { const input = Schema.Struct({ value: Schema.String }) const output = Schema.Struct({ value: Schema.String, internal: Schema.Boolean }) - const structured = Schema.Struct({ value: Schema.String }) - const tool: Tool.Definition = Tool.make({ + const tool = Tool.make({ description: "Annotated tool", input, output, - structured, - toStructuredOutput: ({ output }) => ({ value: output.value }), - execute: ({ value }) => Effect.succeed({ value, internal: true }), + execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }), }) - expect(tool.structured).toBe(structured) + expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({ + output: { value: "out", internal: true }, + metadata: { value: "out" }, + content: "out", + }) +}) + +test("raw JSON schemas are render-only and omitted output means model-only", async () => { + const tool = Tool.make({ + description: "Raw tool", + input: { type: "object", properties: { value: { type: "string" } } }, + execute: (input) => Effect.succeed({ content: JSON.stringify(input) }), + }) + + expect(Tool.toLLMDefinition("raw", tool)).toEqual({ + name: "raw", + description: "Raw tool", + inputSchema: { type: "object", properties: { value: { type: "string" } } }, + }) + expect(await Effect.runPromise(Tool.decodeInput(tool.input, { value: 1 }))).toEqual({ value: 1 }) }) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index cc56a83eab9..faa4853aded 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -409,40 +409,49 @@ export namespace Tool { }) export type Called = typeof Called.Type - /** Live replacement snapshot for a running tool. */ + /** Live replacement metadata for a running tool. */ export const Progress = Event.ephemeral({ type: "session.tool.progress", schema: { ...ToolBase, - structured: Schema.Record(Schema.String, Schema.Unknown), - content: Schema.Array(ToolContent), + metadata: Schema.Record(Schema.String, Schema.Json), }, }) export type Progress = typeof Progress.Type + /** Canonical terminal success: one non-empty model representation plus optional UI metadata. */ export const Success = Event.durable({ type: "session.tool.success", - ...options, + durable: { + aggregate: "sessionID", + version: 2, + }, schema: { ...ToolBase, - structured: Schema.Record(Schema.String, Schema.Unknown), - content: Schema.Array(ToolContent), - result: Schema.Unknown.pipe(optional), + content: Schema.NonEmptyArray(ToolContent), + metadata: Schema.Record(Schema.String, Schema.Json).pipe(optional), executed: Schema.Boolean, resultState: SessionMessage.ProviderState.pipe(optional), }, }) export type Success = typeof Success.Type + /** + * Canonical terminal failure: one error plus the final bounded snapshot of + * partial progress. The event is self-contained; projection never reaches + * into ephemeral progress history. + */ export const Failed = Event.durable({ type: "session.tool.failed", - ...options, + durable: { + aggregate: "sessionID", + version: 2, + }, schema: { ...ToolBase, error: SessionError.Error, content: Schema.NonEmptyArray(ToolContent).pipe(optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), - result: Schema.Unknown.pipe(optional), + metadata: Schema.Record(Schema.String, Schema.Json).pipe(optional), executed: Schema.Boolean, resultState: SessionMessage.ProviderState.pipe(optional), }, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 3d45e283e60..c30ffc6aa46 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -110,27 +110,24 @@ export interface ToolStateRunning extends Schema.Schema.Type {} export const ToolStateCompleted = Schema.Struct({ status: Schema.tag("completed"), input: Schema.Record(Schema.String, Schema.Unknown), - content: ToolContent.pipe(Schema.Array), - structured: Schema.Record(Schema.String, Schema.Unknown), - result: Schema.Unknown.pipe(optional), + content: Schema.NonEmptyArray(ToolContent), + metadata: Schema.Record(Schema.String, Schema.Json).pipe(optional), }).annotate({ identifier: "Session.Message.ToolState.Completed" }) export interface ToolStateError extends Schema.Schema.Type {} export const ToolStateError = Schema.Struct({ status: Schema.tag("error"), input: Schema.Record(Schema.String, Schema.Unknown), - content: ToolContent.pipe(Schema.Array), - structured: Schema.Record(Schema.String, Schema.Unknown), error: SessionError.Error, - result: Schema.Unknown.pipe(optional), + content: Schema.NonEmptyArray(ToolContent).pipe(optional), + metadata: Schema.Record(Schema.String, Schema.Json).pipe(optional), }).annotate({ identifier: "Session.Message.ToolState.Error" }) export const ToolState = Schema.Union([ToolStateStreaming, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 89766d3d262..bd08c653a5c 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -125,8 +125,8 @@ describe("public event manifest", () => { "session.tool.input.started.1", "session.tool.input.ended.1", "session.tool.called.1", - "session.tool.success.1", - "session.tool.failed.1", + "session.tool.success.2", + "session.tool.failed.2", "session.reasoning.started.1", "session.reasoning.ended.1", "session.retry.scheduled.1", diff --git a/packages/sdk-next/src/tool.ts b/packages/sdk-next/src/tool.ts index eff4b809aa3..978ea6d0cbf 100644 --- a/packages/sdk-next/src/tool.ts +++ b/packages/sdk-next/src/tool.ts @@ -1,2 +1,2 @@ export { Failure, RegistrationError, make } from "@opencode-ai/plugin/v2/effect/tool" -export type { AnyTool, Content, Context, Definition } from "@opencode-ai/plugin/v2/effect/tool" +export type { Any, Content, Context, Tool } from "@opencode-ai/plugin/v2/effect/tool" diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 09980bd8cbb..68b8ef8e6ce 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -77,7 +77,7 @@ it.live( description: "Marks the initial Location plugin generation", input: Schema.Struct({}), output: Schema.Void, - execute: () => Effect.void, + execute: () => Effect.succeed({ output: undefined }), }), ), ) @@ -104,7 +104,7 @@ it.live( description: "Tool registered after Location boot", input: Schema.Struct({}), output: Schema.Void, - execute: () => Effect.void, + execute: () => Effect.succeed({ output: undefined }), }), ), ) @@ -225,7 +225,7 @@ it.live( description: "Embedded test tool", input: Schema.Struct({}), output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ ok: true }), + execute: () => Effect.succeed({ output: { ok: true } }), }), ), ) diff --git a/packages/simulation/src/backend/simulated-provider.ts b/packages/simulation/src/backend/simulated-provider.ts index ba64f21bca5..3bb9014df8d 100644 --- a/packages/simulation/src/backend/simulated-provider.ts +++ b/packages/simulation/src/backend/simulated-provider.ts @@ -10,6 +10,7 @@ import { Exit, Fiber, FiberSet, + JsonSchema, Layer, PubSub, Queue, @@ -451,7 +452,7 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* ( name: string, input: unknown, context: Tool.Context, - ): Effect.Effect => + ): Effect.Effect, Tool.Failure> => Effect.gen(function* () { const encoded = yield* Schema.decodeUnknownEffect(Schema.Json)(input).pipe( Effect.mapError((error) => new Tool.Failure({ message: `Simulated tool input is not JSON: ${error.message}` })), @@ -518,7 +519,15 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* ( ), ), ) - if (invocation.type === "success") return invocation.output + // The simulation wire protocol keeps its historical field names; map to the + // canonical output at this boundary. + if (invocation.type === "success") + return { + output: invocation.output.structured, + ...(invocation.output.content.length === 0 + ? {} + : { content: invocation.output.content as [Tool.Content, ...Tool.Content[]] }), + } return yield* Effect.fail(new Tool.Failure({ message: invocation.message })) }) @@ -546,11 +555,8 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* ( registration.name, Tool.make({ description: registration.description, - jsonSchema: registration.inputSchema, - ...(registration.outputSchema === undefined - ? {} - : { outputSchema: registration.outputSchema }), - ...(registration.permission === undefined ? {} : { permission: registration.permission }), + input: registration.inputSchema, + output: registration.outputSchema ?? {}, execute: (input, context) => invoke( generation, @@ -559,7 +565,9 @@ const makeToolDriver = Effect.fn("SimulatedProvider.makeToolDriver")(function* ( context, ), }), - registration.options, + registration.permission === undefined + ? registration.options + : { ...registration.options, permission: registration.permission }, ) }) .pipe(Scope.provide(nextScope)), diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index d97b8086862..573baf1f834 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -473,10 +473,7 @@ export namespace Backend { : `${registration.options.namespace.replaceAll(".", "_")}_${registration.name}` } - export const ToolProgress = Schema.Struct({ - structured: Schema.Record(Schema.String, Schema.Json), - content: Schema.optionalKey(Schema.Array(ToolContent)), - }) + export const ToolProgress = Schema.Record(Schema.String, Schema.Json) export interface ToolProgress extends Schema.Schema.Type {} export const ToolOutput = Schema.Struct({ diff --git a/packages/simulation/test/protocol.test.ts b/packages/simulation/test/protocol.test.ts index b293f354398..34893914c6b 100644 --- a/packages/simulation/test/protocol.test.ts +++ b/packages/simulation/test/protocol.test.ts @@ -127,7 +127,7 @@ test("decodes the simulated tool lifecycle", () => { params: { id: "tool_1", sequence: 0, - update: { structured: { phase: "searching" }, content: [{ type: "text", text: "Searching" }] }, + update: { phase: "searching" }, }, }), ).toMatchObject({ method: "tool.update" }) diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts index 8aac0cd7237..82238bae028 100644 --- a/packages/simulation/test/simulated-provider.test.ts +++ b/packages/simulation/test/simulated-provider.test.ts @@ -241,6 +241,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { additionalProperties: false, }, outputSchema: { type: "object" }, + permission: "simulate_lookup", options: { codemode: false }, } const locations = yield* LocationServiceMap.Service @@ -264,19 +265,22 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { ) expect(yield* Queue.take(messages)).toMatchObject({ id: 1, result: { attached: true } }) const registry = yield* ToolRegistry.Service - const materialized = yield* registry.materialize() - expect(materialized.definitions).toContainEqual( + const toolSet = yield* registry.snapshot() + expect(toolSet.definitions).toContainEqual( expect.objectContaining({ name: "lookup", description: "Look up a value" }), ) - const secondaryMaterialized = yield* ToolRegistry.Service.use((secondaryRegistry) => - secondaryRegistry.materialize(), + expect( + (yield* registry.snapshot([{ action: "simulate_lookup", resource: "*", effect: "deny" }])).definitions, + ).not.toContainEqual(expect.objectContaining({ name: "lookup" })) + const secondaryToolSet = yield* ToolRegistry.Service.use((secondaryRegistry) => + secondaryRegistry.snapshot(), ).pipe(Effect.provide(secondary)) - expect(secondaryMaterialized.definitions).toContainEqual( + expect(secondaryToolSet.definitions).toContainEqual( expect.objectContaining({ name: "lookup", description: "Look up a value" }), ) const progress: ToolRegistry.Progress[] = [] - const settle = (callID: string, query: string) => - materialized.settle({ + const executeCall = (callID: string, query: string) => + toolSet.execute({ sessionID: SessionV2.ID.make("ses_simulated_tools"), agent: AgentV2.ID.make("build"), messageID: SessionMessage.ID.make("msg_simulated_tools"), @@ -289,7 +293,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { }, }) - const successful = yield* settle("call_success", "answer").pipe(Effect.forkScoped) + const successful = yield* executeCall("call_success", "answer").pipe(Effect.forkScoped) const successInvocation = yield* takeToolInvocation(messages) expect(successInvocation.params).toMatchObject({ name: "lookup", @@ -309,10 +313,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { params: { id: successID, sequence: 0, - update: { - structured: { phase: "searching" }, - content: [{ type: "text", text: "Searching" }], - }, + update: { phase: "searching" }, }, }) socket.send(update) @@ -334,7 +335,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { params: { id: successID, sequence: 0, - update: { structured: { phase: "different" } }, + update: { phase: "different" }, }, }), ) @@ -350,7 +351,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { params: { id: successID, sequence: 2, - update: { structured: { phase: "skipped" } }, + update: { phase: "skipped" }, }, }), ) @@ -389,20 +390,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { ) expect(yield* Queue.take(messages)).toMatchObject({ id: 23, result: { ok: true } }) expect(yield* Fiber.join(successful)).toMatchObject({ - result: { type: "text", value: "42" }, - output: { - structured: { answer: 42 }, - content: [{ type: "text", text: "42" }], - }, + status: "completed", + output: { answer: 42 }, + content: [{ type: "text", text: "42" }], }) - expect(progress).toEqual([ - { - structured: { phase: "searching" }, - content: [{ type: "text", text: "Searching" }], - }, - ]) + expect(progress).toEqual([{ phase: "searching" }]) - const failed = yield* settle("call_failure", "missing").pipe(Effect.forkScoped) + const failed = yield* executeCall("call_failure", "missing").pipe(Effect.forkScoped) const failedInvocation = yield* takeToolInvocation(messages) const failedID = requireString(requireRecord(failedInvocation.params).id) socket.send( @@ -415,12 +409,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { ) expect(yield* Queue.take(messages)).toMatchObject({ id: 4, result: { ok: true } }) expect(yield* Fiber.join(failed)).toMatchObject({ - result: { type: "error", value: "lookup failed" }, + status: "error", + error: { message: "lookup failed" }, }) const concurrent = [ - yield* settle("call_first", "first").pipe(Effect.forkScoped), - yield* settle("call_second", "second").pipe(Effect.forkScoped), + yield* executeCall("call_first", "first").pipe(Effect.forkScoped), + yield* executeCall("call_second", "second").pipe(Effect.forkScoped), ] const invocations = [yield* takeToolInvocation(messages), yield* takeToolInvocation(messages)] const byCall = new Map( @@ -447,10 +442,18 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { ) expect(yield* Queue.take(messages)).toMatchObject({ id, result: { ok: true } }) } - expect((yield* Fiber.join(concurrent[0])).result).toEqual({ type: "text", value: "first result" }) - expect((yield* Fiber.join(concurrent[1])).result).toEqual({ type: "text", value: "second result" }) + expect(yield* Fiber.join(concurrent[0])).toMatchObject({ + status: "completed", + output: "first result", + content: [{ type: "text", text: "first result" }], + }) + expect(yield* Fiber.join(concurrent[1])).toMatchObject({ + status: "completed", + output: "second result", + content: [{ type: "text", text: "second result" }], + }) - const cancelled = yield* settle("call_cancelled", "slow").pipe(Effect.forkScoped) + const cancelled = yield* executeCall("call_cancelled", "slow").pipe(Effect.forkScoped) const cancelledInvocation = yield* takeToolInvocation(messages) const cancelledID = requireString(requireRecord(cancelledInvocation.params).id) yield* Fiber.interrupt(cancelled) @@ -474,13 +477,10 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { error: { message: expect.stringContaining("not found or already finished") }, }) - const replayed = yield* settle("call_replayed", "reconnect").pipe(Effect.forkScoped) + const replayed = yield* executeCall("call_replayed", "reconnect").pipe(Effect.forkScoped) const original = yield* takeToolInvocation(messages) const originalID = requireString(requireRecord(original.params).id) - const replayedProgress = { - structured: { phase: "before-reconnect" }, - content: [{ type: "text", text: "Still running" }], - } + const replayedProgress = { phase: "before-reconnect" } socket.send( JSON.stringify({ jsonrpc: "2.0", @@ -505,7 +505,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { error: { message: expect.stringContaining("already attached") }, }) yield* closeSocket(socket) - const disconnected = yield* registry.materialize() + const disconnected = yield* registry.snapshot() expect(disconnected.definitions).toContainEqual(expect.objectContaining({ name: "lookup" })) replacement.send( JSON.stringify({ @@ -547,7 +547,7 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { }), ) expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 26, result: { ok: true } }) - expect(progress.filter((update) => update.structured.phase === "before-reconnect")).toHaveLength(1) + expect(progress.filter((update) => update.phase === "before-reconnect")).toHaveLength(1) replacement.send( JSON.stringify({ jsonrpc: "2.0", @@ -563,12 +563,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { }), ) expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 10, result: { ok: true } }) - expect((yield* Fiber.join(replayed)).result).toEqual({ - type: "text", - value: "replayed result", + expect(yield* Fiber.join(replayed)).toMatchObject({ + status: "completed", + output: "replayed result", + content: [{ type: "text", text: "replayed result" }], }) - const preserved = yield* settle("call_preserved", "same generation").pipe(Effect.forkScoped) + const preserved = yield* executeCall("call_preserved", "same generation").pipe(Effect.forkScoped) const preservedInvocation = yield* takeToolInvocation(replacementMessages) const preservedID = requireString(requireRecord(preservedInvocation.params).id) replacement.send( @@ -583,7 +584,11 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { }), ) expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 27, result: { ok: true } }) - expect((yield* Fiber.join(preserved)).result).toEqual({ type: "text", value: "preserved" }) + expect(yield* Fiber.join(preserved)).toMatchObject({ + status: "completed", + output: "preserved", + content: [{ type: "text", text: "preserved" }], + }) const namespaced = [ { ...registration, name: "search", options: { namespace: "github", codemode: false } }, @@ -598,18 +603,18 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { }), ) expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 11, result: { attached: true } }) - const replaced = yield* registry.materialize() + const replaced = yield* registry.snapshot() const replacedNames = replaced.definitions.map((definition) => definition.name) expect(replacedNames).toEqual(expect.arrayContaining(["github_search", "web_search"])) expect(replacedNames).not.toContain("lookup") const secondaryReplaced = yield* ToolRegistry.Service.use((secondaryRegistry) => - secondaryRegistry.materialize(), + secondaryRegistry.snapshot(), ).pipe(Effect.provide(secondary)) const secondaryNames = secondaryReplaced.definitions.map((definition) => definition.name) expect(secondaryNames).toEqual(expect.arrayContaining(["github_search", "web_search"])) expect(secondaryNames).not.toContain("lookup") const routed = yield* replaced - .settle({ + .execute({ sessionID: SessionV2.ID.make("ses_simulated_tools"), agent: AgentV2.ID.make("build"), messageID: SessionMessage.ID.make("msg_simulated_tools"), @@ -636,9 +641,13 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { }), ) expect(yield* Queue.take(replacementMessages)).toMatchObject({ id: 12, result: { ok: true } }) - expect((yield* Fiber.join(routed)).result).toEqual({ type: "text", value: "routed" }) + expect(yield* Fiber.join(routed)).toMatchObject({ + status: "completed", + output: "routed", + content: [{ type: "text", text: "routed" }], + }) expect( - yield* materialized.settle({ + yield* toolSet.execute({ sessionID: SessionV2.ID.make("ses_simulated_tools"), agent: AgentV2.ID.make("build"), messageID: SessionMessage.ID.make("msg_simulated_tools"), @@ -650,7 +659,8 @@ test("controls arbitrary tools through scoped SDK overlays", async () => { }, }), ).toMatchObject({ - result: { type: "error", value: expect.stringContaining("no longer active") }, + status: "error", + error: { message: expect.stringContaining("no longer active") }, }) expect(activations).toBe(2) }).pipe(Effect.provide(primary)) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index a5a18ff077f..8eb4a190022 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -32,6 +32,7 @@ import type { Plugin } from "@opencode-ai/plugin/v2/tui" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useClient } from "./client" +import { nonEmptyToolContent } from "../util/tool-display" import { createEffect, createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" @@ -605,7 +606,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ match.time.ran = event.created match.executed = event.data.executed match.providerState = event.data.state - match.state = { status: "running", input: event.data.input, structured: {}, content: [] } + match.state = { status: "running", input: event.data.input, metadata: {} } }) break case "session.tool.progress": @@ -615,8 +616,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ event.data.callID, ) if (match?.state.status !== "running") return - match.state.structured = event.data.structured - match.state.content = [...event.data.content] + match.state.metadata = event.data.metadata }) break case "session.tool.success": @@ -629,9 +629,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ match.state = { status: "completed", input: match.state.input, - structured: event.data.structured, + metadata: event.data.metadata, content: [...event.data.content], - result: event.data.result, } match.executed = event.data.executed || match.executed === true match.providerResultState = event.data.resultState @@ -649,9 +648,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ status: "error", error: event.data.error, input: typeof match.state.input === "string" ? {} : match.state.input, - structured: event.data.metadata ?? (match.state.status === "running" ? match.state.structured : {}), - content: event.data.content ?? (match.state.status === "running" ? match.state.content : []), - result: event.data.result, + metadata: event.data.metadata, + content: event.data.content, } match.executed = event.data.executed || match.executed === true match.providerResultState = event.data.resultState diff --git a/packages/tui/src/mini/demo.ts b/packages/tui/src/mini/demo.ts index a16ae991ac2..dcf6bf35372 100644 --- a/packages/tui/src/mini/demo.ts +++ b/packages/tui/src/mini/demo.ts @@ -342,13 +342,13 @@ function make(state: State, tool: string, input: Record): Ref } } -function startTool(state: State, ref: Ref, structured: Record = {}): SessionMessageAssistantTool { +function startTool(state: State, ref: Ref, metadata: Record = {}): SessionMessageAssistantTool { state.started.add(ref.call) const part = { type: "tool" as const, id: ref.call, name: ref.tool, - state: { status: "running" as const, input: ref.input, structured, content: [] }, + state: { status: "running" as const, input: ref.input, metadata }, time: { created: ref.start, ran: ref.start }, } present(state, [toolCommit(part, ref.msg, "start")]) @@ -395,8 +395,8 @@ function doneTool( state: { status: "completed", input: ref.input, - content: output.output ? [{ type: "text", text: output.output }] : [], - structured: output.metadata ?? {}, + content: [{ type: "text", text: output.output }], + metadata: output.metadata, }, time: { created: ref.start, ran: ref.start, completed: Date.now() }, } @@ -415,8 +415,6 @@ function failTool(state: State, ref: Ref, error: string): void { status: "error", input: ref.input, error: { type: "unknown", message: error }, - structured: {}, - content: [], }, time: { created: ref.start, ran: ref.start, completed: Date.now() }, }, @@ -527,8 +525,7 @@ function emitTask(state: State): void { offset: 1, limit: 200, }, - structured: {}, - content: [], + metadata: {}, }, time: { created: Date.now(), ran: Date.now() }, } satisfies SessionMessageAssistantTool diff --git a/packages/tui/src/mini/permission.shared.ts b/packages/tui/src/mini/permission.shared.ts index e293d3ef17b..8294a5abe50 100644 --- a/packages/tui/src/mini/permission.shared.ts +++ b/packages/tui/src/mini/permission.shared.ts @@ -53,7 +53,7 @@ export function permissionInfo(request: MiniPermissionRequest, directory?: strin resources: request.resources, metadata: request.metadata, input: state?.status === "streaming" ? undefined : state?.input, - structured: state?.status === "streaming" ? undefined : state?.structured, + toolMetadata: state?.status === "streaming" ? undefined : state?.metadata, }, (value) => toolPath(value, { home: true, directory }), ) diff --git a/packages/tui/src/mini/stream-v2.subagent.ts b/packages/tui/src/mini/stream-v2.subagent.ts index ea3b566af43..95e9719f869 100644 --- a/packages/tui/src/mini/stream-v2.subagent.ts +++ b/packages/tui/src/mini/stream-v2.subagent.ts @@ -1,7 +1,7 @@ // Current-native subagent (child Session) tracking for the mini transport. // // Discovers child Sessions of the active parent from four current sources: -// 1. projected subagent tool output (`structured.sessionID`) during hydration +// 1. projected subagent tool output (`metadata.sessionID`) during hydration // 2. the current session list filtered by `parentID` during hydration // 3. the process-local active-session map during hydration // 4. live events from unknown sessions whose `parentID` matches the parent @@ -33,6 +33,7 @@ import type { StreamCommit, } from "./types" import { canonicalToolName, normalizeTool, toolOutputText, toolView } from "./tool" +import { toolDisplayContent } from "../util/tool-display" const CHILD_MESSAGE_LIMIT = 80 const CHILD_FRAME_LIMIT = 80 @@ -55,7 +56,7 @@ export function toolCommit( ): StreamCommit { const part = normalizeTool(input) const status = part.state.status - const output = status === "streaming" ? "" : toolOutputText(part.name, part.state.content) + const output = status === "streaming" ? "" : toolOutputText(part.name, toolDisplayContent(part.state)) const partial = status === "error" && phase === "progress" && value !== undefined const text = status === "running" || partial @@ -178,10 +179,10 @@ function blockerCategory(event: V2Event): "permission" | "form" | undefined { if (event.type === "form.created" || event.type === "form.replied" || event.type === "form.cancelled") return "form" } -function childSessionID(structured: Record | undefined) { - const sessionID = text(structured?.sessionID) +function childSessionID(metadata: Record | undefined) { + const sessionID = text(metadata?.sessionID) if (!sessionID || !sessionID.startsWith("ses")) return undefined - const status = structured?.status + const status = metadata?.status if (status !== "running" && status !== "completed") return undefined return { sessionID, running: status === "running" } } @@ -199,7 +200,7 @@ function tab(child: ChildState): FooterSubagentTab { export function createSubagentTracker(input: SubagentTrackerInput): SubagentTracker { const children = new Map() - // Live subagent tool calls in the parent, so tool.success structured output + // Live subagent tool calls in the parent, so tool.success metadata // can be joined with the call's input metadata. const pendingCalls = new Map>() // Recently resolved non-family sessions. Retention is bounded so unrelated @@ -309,7 +310,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac return } const current = child.tools.get(key) - const output = toolOutputText(part.name, part.state.content) + const output = toolOutputText(part.name, toolDisplayContent(part.state)) if (part.state.status === "running") { if (!current || current.part.state.status === "streaming") setFrame(child, frame, toolCommit(part, messageID, "start", undefined, input.directory)) @@ -779,7 +780,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac name: current?.part.name ?? "tool", executed: event.data.executed, providerState: event.data.state, - state: { status: "running", input: event.data.input, structured: {}, content: [] }, + state: { status: "running", input: event.data.input, metadata: {} }, time: { created: current?.part.time.created ?? event.created, ran: event.created }, }, event.data.assistantMessageID, @@ -804,8 +805,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac state: { status: "running", input: part && part.state.status !== "streaming" ? part.state.input : {}, - structured: event.data.structured, - content: event.data.content, + metadata: event.data.metadata, }, time: { created: part?.time.created ?? event.created, @@ -837,18 +837,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac ? { status: "error", input: part && part.state.status !== "streaming" ? part.state.input : {}, - structured: - event.data.metadata ?? (part && part.state.status !== "streaming" ? part.state.structured : {}), - content: event.data.content ?? (part && part.state.status !== "streaming" ? part.state.content : []), + metadata: event.data.metadata, + content: event.data.content, error: event.data.error, - result: event.data.result, } : { status: "completed", input: part && part.state.status !== "streaming" ? part.state.input : {}, - structured: event.data.structured, + metadata: event.data.metadata, content: event.data.content, - result: event.data.result, }, time: { created: part?.time.created ?? event.created, @@ -921,7 +918,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac const mainTool = (item: SessionMessageAssistantTool, active?: Record) => { const tool = normalizeTool(item) if (tool.name !== "subagent" || tool.state.status === "streaming") return - const found = childSessionID(record(tool.state.structured)) + const found = childSessionID(record(tool.state.metadata)) if (!found) return const child = admitChild(found.sessionID) if (!child) return @@ -967,7 +964,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac const key = sourceKey(event.data.assistantMessageID, event.data.callID) const pending = pendingCalls.get(key) if (event.type !== "session.tool.progress") pendingCalls.delete(key) - const found = childSessionID(record(event.type === "session.tool.failed" ? event.data.metadata : event.data.structured)) + const found = childSessionID(record(event.data.metadata)) if (!found) return const child = admitChild(found.sessionID) if (!child) return @@ -1085,11 +1082,13 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac input.emit() }, snapshot() { - const tabs = [...children.values()].toSorted((a, b) => { - const active = Number(b.status === "running") - Number(a.status === "running") - if (active !== 0) return active - return b.lastUpdatedAt - a.lastUpdatedAt - }).map(tab) + const tabs = [...children.values()] + .toSorted((a, b) => { + const active = Number(b.status === "running") - Number(a.status === "running") + if (active !== 0) return active + return b.lastUpdatedAt - a.lastUpdatedAt + }) + .map(tab) const child = selected ? children.get(selected) : undefined const details: Record = child && !child.detailStale ? { [child.sessionID]: { commits: child.frames.map((item) => item.commit) } } : {} diff --git a/packages/tui/src/mini/stream-v2.transport.ts b/packages/tui/src/mini/stream-v2.transport.ts index 496bda83b38..e0e767ca56d 100644 --- a/packages/tui/src/mini/stream-v2.transport.ts +++ b/packages/tui/src/mini/stream-v2.transport.ts @@ -16,6 +16,7 @@ import { writeSessionOutput } from "./stream" import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "./stream-v2.fragment" import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent" import { normalizeTool, toolOutputText } from "./tool" +import { toolDisplayContent } from "../util/tool-display" import type { FooterApi, FooterView, @@ -558,7 +559,7 @@ export async function createSessionTransport(input: StreamInput): Promise) { +export function toolOutputText(name: string, content: ReadonlyArray<{ type: string; text?: string }> | undefined) { + if (!content) return "" // V2 shell content appends model-only status after the user-visible command output. if (canonicalToolName(name) === "shell") return content.find((item) => item.type === "text")?.text ?? "" - return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n") + const joined = content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n") + if (canonicalToolName(name) === "read") return readDisplayText(joined) ?? joined + return joined +} + +/** Read's model content is a JSON page envelope; unwrap the human-facing text. */ +export function readDisplayText(text: string): string | undefined { + if (!text.startsWith("{")) return undefined + const parsed = (() => { + try { + return JSON.parse(text) as unknown + } catch { + return undefined + } + })() + const envelope = dict(parsed) + if (typeof envelope.content === "string" && (envelope.type === "text-page" || envelope.encoding === "utf8")) + return envelope.content + if (!Array.isArray(envelope.entries)) return undefined + return envelope.entries + .flatMap((entry): string[] => { + if (typeof entry === "string") return [entry] + const path = dict(entry).path + return typeof path === "string" ? [path] : [] + }) + .join("\n") } function normalizeInput(name: string, value: unknown) { @@ -202,16 +229,16 @@ function normalizeFile(value: unknown): PatchFile | undefined { } } -function normalizeStructured(name: string, value: unknown) { - const structured = dict(value) - const files = list(structured.files).flatMap((item) => { +function normalizeMetadata(name: string, value: unknown) { + const metadata = dict(value) + const files = list(metadata.files).flatMap((item) => { const file = normalizeFile(item) return file ? [file] : [] }) - const sessionID = text(structured.sessionID) || text(structured.sessionId) + const sessionID = text(metadata.sessionID) || text(metadata.sessionId) return { - ...structured, - ...(["edit", "patch"].includes(name) && Array.isArray(structured.files) ? { files } : {}), + ...metadata, + ...(["edit", "patch"].includes(name) && Array.isArray(metadata.files) ? { files } : {}), ...(name === "subagent" && sessionID ? { sessionID } : {}), } } @@ -225,7 +252,7 @@ export function normalizeTool(tool: SessionMessageAssistantTool): SessionMessage state: { ...tool.state, input: normalizeInput(name, tool.state.input), - structured: normalizeStructured(name, toolDisplayMetadata(tool.state)), + metadata: normalizeMetadata(name, toolDisplayMetadata(tool.state)), }, } as SessionMessageAssistantTool } @@ -1089,13 +1116,13 @@ function frame(part: SessionMessageAssistantTool, directory?: string): ToolFrame output: "", time: { start: tool.time.created }, } - const output = toolOutputText(tool.name, tool.state.content) + const output = toolOutputText(tool.name, toolDisplayContent(tool.state)) return { directory, raw: output, name: tool.name, input: normalizeInput(tool.name, tool.state.input), - meta: normalizeStructured(tool.name, tool.state.structured), + meta: normalizeMetadata(tool.name, tool.state.metadata), state: dict(tool.state), status: tool.state.status, error: tool.state.status === "error" ? tool.state.error.message : "", diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 9b03a404225..d4f9f2f00a9 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -42,6 +42,7 @@ import { canonicalToolName, finiteNumber, primitiveInputSummary, + toolDisplayContent, toolDisplayMetadata, webSearchProviderLabel, } from "../../util/tool-display" @@ -2146,7 +2147,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { }, get output() { if (props.part.state.status === "streaming") return undefined - return props.part.state.content + return toolDisplayContent(props.part.state) .flatMap((content) => (content.type === "text" ? [content.text] : [content.name ?? content.uri])) .join("\n") }, @@ -2548,6 +2549,8 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) { ) } +const SHELL_DISPLAY_LIMIT = 1024 * 1024 + function Shell(props: ToolProps) { const { themeV2 } = useTheme() const ctx = use() @@ -2559,6 +2562,7 @@ function Shell(props: ToolProps) { }) const color = createMemo(() => (permission() ? themeV2.text.feedback.warning.default : themeV2.text.default)) const shellID = createMemo(() => stringValue(props.metadata.shellID)) + const background = createMemo(() => Boolean(shellID()) && props.part.state.status !== "running") const backgroundRunning = createMemo(() => { const id = shellID() return Boolean(id && data.shell.get(id)) @@ -2567,31 +2571,73 @@ function Shell(props: ToolProps) { const command = createMemo(() => stringValue(props.input.command)) const [expanded, setExpanded] = createSignal(false) const [backgroundOutput, setBackgroundOutput] = createSignal("") + const [outputTruncated, setOutputTruncated] = createSignal(false) let loading = false - const loadBackgroundOutput = async () => { + let drainRequested = false + let cursor = 0 + let wasRunning = false + const loadBackgroundOutput = async (drain = false) => { const id = shellID() - if (!id || loading) return + if (!id) return + if (loading) { + if (drain) drainRequested = true + return + } loading = true const location = data.session.get(ctx.sessionID)?.location - await client.api.shell - .output({ - id, - limit: 1024 * 1024, - location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined, - }) - .then((response) => setBackgroundOutput(stripAnsi(response.data.output.trim()))) - .catch(() => undefined) + do { + const response = await client.api.shell + .output({ + id, + cursor, + limit: SHELL_DISPLAY_LIMIT, + location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined, + }) + .catch(() => undefined) + if (!response) break + if (response.data.output) + setBackgroundOutput((output) => { + const next = stripAnsi(output + response.data.output) + if (next.length <= SHELL_DISPLAY_LIMIT) return next + setOutputTruncated(true) + return next.slice(-SHELL_DISPLAY_LIMIT) + }) + if (response.data.cursor <= cursor) break + cursor = response.data.cursor + if (!drain || cursor >= response.data.size) break + const tail = Math.max(cursor, response.data.size - SHELL_DISPLAY_LIMIT) + if (tail > cursor) { + cursor = tail + setOutputTruncated(true) + } + } while (true) loading = false + if (drainRequested) { + drainRequested = false + void loadBackgroundOutput(true) + } } createEffect(() => { - if (!expanded() || !backgroundRunning()) return + const running = backgroundRunning() + if (!running) { + if (wasRunning) void loadBackgroundOutput(true) + wasRunning = false + return + } + wasRunning = true + if (background() && !expanded()) return + void loadBackgroundOutput() const interval = setInterval(() => void loadBackgroundOutput(), 1_000) onCleanup(() => clearInterval(interval)) }) const output = createMemo(() => { if (props.part.state.status === "streaming") return "" - if (shellID()) return expanded() ? backgroundOutput() : "" - const content = props.part.state.content[0] + if (shellID()) { + if (background() && !expanded()) return "" + const text = backgroundOutput().trim() + return outputTruncated() ? `[earlier output omitted]\n${text}` : text + } + const content = toolDisplayContent(props.part.state)[0] return stripAnsi(content?.type === "text" ? content.text.trim() : "") }) const maxLines = 10 @@ -2607,7 +2653,7 @@ function Shell(props: ToolProps) { const toggle = () => { const next = !expanded() setExpanded(next) - if (next) void loadBackgroundOutput() + if (next) void loadBackgroundOutput(!backgroundRunning()) } return ( @@ -2638,7 +2684,7 @@ function Shell(props: ToolProps) { - + Background @@ -3154,7 +3200,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI ? item.state.error.message : item.state.status === "streaming" ? "" - : item.state.content + : toolDisplayContent(item.state) .flatMap((entry) => (entry.type === "text" ? [entry.text] : [entry.name ?? entry.uri])) .join("\n") return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`] diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 403e682f6f2..a6a746d881b 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -118,14 +118,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director const source = createMemo(() => { const tool = props.request.source - if (!tool) return { input: undefined, structured: undefined } + if (!tool) return { input: undefined, metadata: undefined } const message = data.session.message.get(props.request.sessionID, tool.messageID) - if (message?.type !== "assistant") return { input: undefined, structured: undefined } + if (message?.type !== "assistant") return { input: undefined, metadata: undefined } const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID) if (part?.type === "tool" && part.state.status !== "streaming") { - return { input: part.state.input, structured: part.state.structured } + return { input: part.state.input, metadata: part.state.metadata } } - return { input: undefined, structured: undefined } + return { input: undefined, metadata: undefined } }) const { themeV2 } = useTheme() @@ -182,7 +182,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director resources: props.request.resources, metadata: props.request.metadata, input: source().input, - structured: source().structured, + toolMetadata: source().metadata, }, pathFormatter.format, ) diff --git a/packages/tui/src/util/permission.ts b/packages/tui/src/util/permission.ts index 966ee670a0a..165943c4134 100644 --- a/packages/tui/src/util/permission.ts +++ b/packages/tui/src/util/permission.ts @@ -17,7 +17,7 @@ export type PermissionPresentationInput = { resources: ReadonlyArray metadata?: unknown input?: unknown - structured?: unknown + toolMetadata?: unknown } export function permissionPresentation( @@ -26,7 +26,7 @@ export function permissionPresentation( ): PermissionPresentation { const action = canonicalToolName(source.action) const input = normalizeInput(action, source.input) - const metadata = { ...dict(source.structured), ...dict(source.metadata) } + const metadata = { ...dict(source.toolMetadata), ...dict(source.metadata) } const resources = source.resources.filter((item): item is string => typeof item === "string") if (action === "edit") { diff --git a/packages/tui/src/util/tool-display.ts b/packages/tui/src/util/tool-display.ts index d181545341e..25e333456b9 100644 --- a/packages/tui/src/util/tool-display.ts +++ b/packages/tui/src/util/tool-display.ts @@ -28,7 +28,19 @@ export function webSearchProviderLabel(provider: unknown) { export function toolDisplayMetadata(state: unknown): Record { if (!state || typeof state !== "object" || Array.isArray(state)) return {} if (!("status" in state) || state.status === "streaming") return {} - if (!("structured" in state) || !state.structured || typeof state.structured !== "object") return {} - if (Array.isArray(state.structured)) return {} - return state.structured as Record + if (!("metadata" in state) || !state.metadata || typeof state.metadata !== "object") return {} + if (Array.isArray(state.metadata)) return {} + return state.metadata as Record } + +export function toolDisplayContent(state: SessionMessageAssistantTool["state"]) { + if (state.status === "streaming" || state.status === "running") return [] + return state.content ?? [] +} + +export function nonEmptyToolContent(content: ReadonlyArray | undefined): [T, ...T[]] | undefined { + if (!content) return undefined + const [first, ...rest] = content + return first === undefined ? undefined : [first, ...rest] +} +import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise" diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index e7e6146f4b5..3b4e5569f87 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -2342,8 +2342,7 @@ test("settles pending tools when a live failure arrives", async () => { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", callID: "call-1", - structured: { sessionID: "session-child", status: "running" }, - content: [], + metadata: { sessionID: "session-child", status: "running" }, }, }) @@ -2353,7 +2352,7 @@ test("settles pending tools when a live failure arrives", async () => { assistant?.type === "assistant" && assistant.content[0]?.type === "tool" && assistant.content[0].state.status === "running" && - assistant.content[0].state.structured.sessionID === "session-child" + assistant.content[0].state.metadata.sessionID === "session-child" ) }) @@ -2361,7 +2360,7 @@ test("settles pending tools when a live failure arrives", async () => { id: "evt_failed_1", created: 0, type: "session.tool.failed", - durable: durable("session-1", 6), + durable: durable("session-1", 6, 2), data: { sessionID: "session-1", assistantMessageID: "msg_explicit_assistant_9", @@ -2392,8 +2391,8 @@ test("settles pending tools when a live failure arrives", async () => { if (tool.state.status !== "error") return expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" }) expect(tool.state.input).toEqual({}) - expect(tool.state.structured).toEqual({ sessionID: "session-child", status: "running" }) - expect(tool.state.content).toEqual([]) + expect(tool.state.metadata).toBeUndefined() + expect(tool.state.content).toBeUndefined() expect(tool.executed).toBe(false) expect(tool.providerState).toEqual({ call: true }) expect(tool.providerResultState).toEqual({ result: true }) diff --git a/packages/tui/test/mini/entry.body.test.ts b/packages/tui/test/mini/entry.body.test.ts index a5695305c49..14b1a035b91 100644 --- a/packages/tui/test/mini/entry.body.test.ts +++ b/packages/tui/test/mini/entry.body.test.ts @@ -133,8 +133,8 @@ describe("run entry body", () => { path: "src/a.ts", content: "const x = 1\n", }, - structured: {}, - content: [], + metadata: {}, + content: [{ type: "text", text: "" }], }, }), snapshot: { @@ -153,10 +153,10 @@ describe("run entry body", () => { input: { path: "src/a.ts", }, - structured: { + metadata: { files: [{ file: "src/a.ts", status: "modified", patch: "@@ -1 +1 @@\n-old\n+new\n" }], }, - content: [], + content: [{ type: "text", text: "" }], }, }), snapshot: { @@ -177,8 +177,8 @@ describe("run entry body", () => { state: { status: "completed", input: {}, - content: [], - structured: { + content: [{ type: "text", text: "" }], + metadata: { files: [ { status: "modified", @@ -221,8 +221,7 @@ describe("run entry body", () => { description: "Inspect reducer", agent: "explore", }, - structured: { sessionID: "ses-child-1", status: "running" }, - content: [], + metadata: { sessionID: "ses-child-1", status: "running" }, }, }), ), @@ -243,7 +242,7 @@ describe("run entry body", () => { agent: "explore", }, content: [{ type: "text", text: "# Findings\n\n- Footer stays live" }], - structured: { + metadata: { sessionID: "ses-child-1", status: "completed", output: "# Findings\n\n- Footer stays live", @@ -266,8 +265,8 @@ describe("run entry body", () => { description: "Inspect reducer", agent: "explore", }, - content: [], - structured: { + content: [{ type: "text", text: "" }], + metadata: { sessionID: "ses-child-1", status: "completed", output: "", @@ -341,7 +340,7 @@ describe("run entry body", () => { workdir: "/tmp/demo", }, content: [{ type: "text", text: output }], - structured: { exit: 0, truncated: false }, + metadata: { exit: 0, truncated: false }, }, }), ), @@ -364,8 +363,7 @@ describe("run entry body", () => { input: { command: "ls", }, - structured: {}, - content: [], + metadata: {}, }, }), ), @@ -435,8 +433,8 @@ describe("run entry body", () => { input: { patchText: "*** Begin Patch\n*** End Patch", }, - content: [], - structured: { + content: [{ type: "text", text: "" }], + metadata: { files: [ { status: "modified", @@ -463,8 +461,8 @@ describe("run entry body", () => { input: { patchText: "*** Begin Patch\n*** End Patch", }, - content: [], - structured: { + content: [{ type: "text", text: "" }], + metadata: { files: [ { status: "modified", @@ -499,8 +497,7 @@ describe("run entry body", () => { path: "/tmp/demo/run", }, error: { type: "unknown", message: "No such file or directory: '/tmp/demo/run'" }, - structured: {}, - content: [], + metadata: {}, }, }), ), @@ -520,11 +517,11 @@ describe("run entry body", () => { state: { status: "completed", input: { target: "demo" }, - structured: { + metadata: { result: { ok: true, nested: { values: Array.from({ length: 40 }, (_, index) => ({ index })) } }, large: "x".repeat(8_000), }, - content: [], + content: [{ type: "text", text: "" }], }, }), ) diff --git a/packages/tui/test/mini/permission.shared.test.ts b/packages/tui/test/mini/permission.shared.test.ts index 5a5058523a0..2b73f0aae7d 100644 --- a/packages/tui/test/mini/permission.shared.test.ts +++ b/packages/tui/test/mini/permission.shared.test.ts @@ -95,8 +95,7 @@ describe("run permission shared", () => { { status: "running", input: { command: "git status --short" }, - structured: {}, - content: [], + metadata: {}, }, "call-shell", ), @@ -141,8 +140,7 @@ describe("run permission shared", () => { { status: "running", input: { query: "current releases" }, - structured: { provider: "exa", retained: true }, - content: [], + metadata: { provider: "exa", retained: true }, }, "call-search", ), @@ -165,8 +163,7 @@ describe("run permission shared", () => { { status: "running", input: { patchText: patch }, - structured: {}, - content: [], + metadata: {}, }, "call-edit", ), diff --git a/packages/tui/test/mini/scrollback.surface.test.ts b/packages/tui/test/mini/scrollback.surface.test.ts index 5db71b1045f..e2fe8226819 100644 --- a/packages/tui/test/mini/scrollback.surface.test.ts +++ b/packages/tui/test/mini/scrollback.surface.test.ts @@ -217,7 +217,7 @@ test("renders monochrome scrollback as ASCII markdown", async () => { try { await out.scrollback.append(assistant("# H")) expect(Reflect.get(out.scrollback, "active")?.renderable).toBeInstanceOf(MarkdownRenderable) - await out.scrollback.append(assistant('éading →\n\n> “quote”\n\n---\n\n| A | B |\n| - | - |\n| α | β |')) + await out.scrollback.append(assistant("éading →\n\n> “quote”\n\n---\n\n| A | B |\n| - | - |\n| α | β |")) await out.scrollback.complete() out.renderer.writeToScrollback((ctx) => ({ root: new TextRenderable(ctx.renderContext, { @@ -386,8 +386,7 @@ test("renders question summaries without boilerplate footer copy", async () => { }, ], }, - structured: {}, - content: [], + metadata: {}, }, }), final: toolCommit({ @@ -406,10 +405,10 @@ test("renders question summaries without boilerplate footer copy", async () => { }, ], }, - structured: { + metadata: { answers: [["Bug fix"]], }, - content: [], + content: [{ type: "text", text: "" }], }, }), }, @@ -481,8 +480,7 @@ test("inserts spacers for new visible groups", async () => { input: { pattern: "**/run.ts", }, - structured: {}, - content: [], + metadata: {}, }, }), ) @@ -617,8 +615,7 @@ test("does not double-space before completed shell output when inline tool heade command: "ls", workdir: "src/cli/cmd/run", }, - structured: {}, - content: [], + metadata: {}, }, }), ) @@ -634,8 +631,7 @@ test("does not double-space before completed shell output when inline tool heade pattern: "**/*tool*", path: "src/cli/cmd/run", }, - structured: {}, - content: [], + metadata: {}, }, }), ) @@ -651,8 +647,7 @@ test("does not double-space before completed shell output when inline tool heade pattern: "tool", path: "src/cli/cmd/run", }, - structured: {}, - content: [], + metadata: {}, }, }), ) @@ -670,7 +665,7 @@ test("does not double-space before completed shell output when inline tool heade workdir: "src/cli/cmd/run", }, content: [{ type: "text", text: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n") }], - structured: { exit: 0, truncated: false }, + metadata: { exit: 0, truncated: false }, }, }), ) @@ -735,8 +730,7 @@ test("renders structured write finals once as code blocks", async () => { path: "src/a.ts", content: "const x = 1\nconst y = 2\n", }, - structured: {}, - content: [], + metadata: {}, }, }), ) @@ -755,8 +749,8 @@ test("renders structured write finals once as code blocks", async () => { path: "src/a.ts", content: "const x = 1\nconst y = 2\n", }, - structured: {}, - content: [], + metadata: {}, + content: [{ type: "text", text: "" }], }, }), ) diff --git a/packages/tui/test/mini/stream-v2.transport.test.ts b/packages/tui/test/mini/stream-v2.transport.test.ts index f2c72ea6f05..b4ce61ef8d1 100644 --- a/packages/tui/test/mini/stream-v2.transport.test.ts +++ b/packages/tui/test/mini/stream-v2.transport.test.ts @@ -234,8 +234,7 @@ describe("V2 mini transport", () => { sessionID: "ses_1", thinking: false, footer: ui.api, - contextLimit: (model) => - model.providerID === "test" && model.modelID === "model" ? 160_000 : undefined, + contextLimit: (model) => (model.providerID === "test" && model.modelID === "model" ? 160_000 : undefined), }) events.push({ @@ -324,8 +323,7 @@ describe("V2 mini transport", () => { { status: "running" as const, input: { command: "git status --short" }, - structured: {}, - content: [], + metadata: {}, }, "call_child_source", ), @@ -620,8 +618,8 @@ describe("V2 mini transport", () => { expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }), ) expect(pending()).toEqual([]) - const prompt = spyOn(client.session, "prompt").mockImplementation((request) => - ok({ ...promptAdmission(request), admittedSeq: 2 }) as never, + const prompt = spyOn(client.session, "prompt").mockImplementation( + (request) => ok({ ...promptAdmission(request), admittedSeq: 2 }) as never, ) await transport.queuePromptTurn({ agent: "review", @@ -631,10 +629,7 @@ describe("V2 mini transport", () => { files: [], includeFiles: false, }) - expect(client.session.switchAgent).toHaveBeenCalledWith( - { sessionID: "ses_1", agent: "review" }, - expect.anything(), - ) + expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything()) expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything()) events.push({ id: "evt_earlier_admission", @@ -1976,13 +1971,13 @@ describe("V2 mini transport", () => { id: `evt_repeated_success_${index}`, created: index * 3 + 3, type: "session.tool.success", - durable: durable("ses_1", index * 3 + 2), + durable: durable("ses_1", index * 3 + 2, 2), data: { sessionID: "ses_1", assistantMessageID: messageID, callID: "call_repeated", - structured: {}, - content: [], + metadata: {}, + content: [{ type: "text", text: "" }], executed: true, }, }) @@ -2048,15 +2043,14 @@ describe("V2 mini transport", () => { sessionID: "ses_1", assistantMessageID: "msg_progress", callID: "call_progress", - structured: { checkpoint: 1 }, - content: [{ type: "text", text: "partial" }], + metadata: { checkpoint: 1 }, }, }) events.push({ id: "evt_progress_failed", created: 4, type: "session.tool.failed", - durable: durable("ses_1", 3), + durable: durable("ses_1", 3, 2), data: { sessionID: "ses_1", assistantMessageID: "msg_progress", @@ -2077,7 +2071,7 @@ describe("V2 mini transport", () => { ]) expect(commits.at(-1)?.part?.state).toMatchObject({ status: "error", - structured: { checkpoint: 1 }, + metadata: { checkpoint: 1 }, content: [{ type: "text", text: "partial" }], }) await transport.close() @@ -2649,9 +2643,7 @@ describe("V2 mini transport", () => { ], command: { name: "deploy", arguments: "prod" }, }, - files: [ - { type: "file", url: "file:///tmp/context.txt", filename: "context.txt", mime: "text/plain" }, - ], + files: [{ type: "file", url: "file:///tmp/context.txt", filename: "context.txt", mime: "text/plain" }], includeFiles: true, }) @@ -2732,10 +2724,7 @@ describe("V2 mini transport", () => { includeFiles: true, }) - expect(client.session.switchAgent).toHaveBeenCalledWith( - { sessionID: "ses_1", agent: "review" }, - expect.anything(), - ) + expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything()) expect(request).toMatchObject({ sessionID: "ses_1", id: "msg_skill", skill: "tigerstyle" }) expect(command).not.toHaveBeenCalled() expect(prompt).not.toHaveBeenCalled() @@ -2886,7 +2875,7 @@ describe("V2 mini transport", () => { id: "evt_failed_subagent", created: 3, type: "session.tool.failed", - durable: durable("ses_1", 2), + durable: durable("ses_1", 2, 2), data: { sessionID: "ses_1", assistantMessageID: "msg_failed_subagent", @@ -2897,8 +2886,7 @@ describe("V2 mini transport", () => { }, }) - while (!states().some((state) => state.tabs.some((tab) => tab.sessionID === "ses_child_failed"))) - await Bun.sleep(0) + while (!states().some((state) => state.tabs.some((tab) => tab.sessionID === "ses_child_failed"))) await Bun.sleep(0) expect(states().at(-1)?.tabs).toMatchObject([ { sessionID: "ses_child_failed", @@ -2954,8 +2942,7 @@ describe("V2 mini transport", () => { sessionID: "ses_1", assistantMessageID: "msg_subagent", callID: "call_subagent", - structured: { sessionID: "ses_child_progress", status: "running" }, - content: [], + metadata: { sessionID: "ses_child_progress", status: "running" }, }, }) while (!states().some((state) => state.tabs.some((tab) => tab.sessionID === "ses_child_progress"))) @@ -3005,8 +2992,7 @@ describe("V2 mini transport", () => { sessionID: "ses_child_progress", assistantMessageID: "msg_child_tool", callID: "call_child_shell", - structured: { checkpoint: "child" }, - content: [{ type: "text", text: "child partial" }], + metadata: { checkpoint: "child" }, }, }) events.push({ @@ -3025,7 +3011,7 @@ describe("V2 mini transport", () => { id: "evt_child_tool_failed", created: 8, type: "session.tool.failed", - durable: durable("ses_child_progress", 3), + durable: durable("ses_child_progress", 3, 2), data: { sessionID: "ses_child_progress", assistantMessageID: "msg_child_tool", @@ -3058,7 +3044,7 @@ describe("V2 mini transport", () => { commits.find((item) => item.part?.id === "call_child_shell" && item.toolState === "error")?.part?.state, ).toMatchObject({ status: "error", - structured: { checkpoint: "child" }, + metadata: { checkpoint: "child" }, content: [{ type: "text", text: "child partial" }], }) expect( @@ -3145,7 +3131,11 @@ describe("V2 mini transport", () => { { sessionID: "ses_child", label: "Explore", title: "Find files", status: "running" }, ]) - expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1) + expect( + states() + .at(-1) + ?.details.ses_child?.commits.filter((item) => item.text === "child answer"), + ).toHaveLength(1) events.push({ id: "evt_child_text_replayed", @@ -3159,7 +3149,11 @@ describe("V2 mini transport", () => { }, }) await Bun.sleep(0) - expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1) + expect( + states() + .at(-1) + ?.details.ses_child?.commits.filter((item) => item.text === "child answer"), + ).toHaveLength(1) events.push({ id: "evt_child_text_suffix", @@ -3172,7 +3166,9 @@ describe("V2 mini transport", () => { delta: " suffix", }, }) - while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer suffix"))) + while ( + !states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer suffix")) + ) await Bun.sleep(0) events.push({ @@ -3437,7 +3433,7 @@ describe("V2 mini transport", () => { status: "completed" as const, input: { command: "projected" }, content: [{ type: "text" as const, text: "projected result" }], - structured: {}, + metadata: {}, }, time: { created: 1, ran: 1, completed: 2 }, }, @@ -3488,12 +3484,12 @@ describe("V2 mini transport", () => { id: "evt_success_terminal", created: 2, type: "session.tool.success", - durable: durable("ses_child", 2), + durable: durable("ses_child", 2, 2), data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID: "call_terminal", - structured: {}, + metadata: {}, content: [{ type: "text", text: "found" }], executed: true, }, @@ -3651,13 +3647,13 @@ describe("V2 mini transport", () => { id: "evt_parent_success", created: 0, type: "session.tool.success", - durable: durable("ses_1", 1), + durable: durable("ses_1", 1, 2), data: { sessionID: "ses_1", assistantMessageID: "msg_parent_a", callID: "call_sub", - structured: { sessionID: "ses_child", status: "running", output: "" }, - content: [], + metadata: { sessionID: "ses_child", status: "running", output: "" }, + content: [{ type: "text", text: "" }], executed: true, }, }) @@ -3738,7 +3734,7 @@ describe("V2 mini transport", () => { status: "completed" as const, input: { agent: "explore", description: "Find things", prompt: "go" }, content: [{ type: "text" as const, text: "done" }], - structured: { sessionID: "ses_child", status: "completed", output: "done" }, + metadata: { sessionID: "ses_child", status: "completed", output: "done" }, }, time: { created: 1, ran: 1, completed: 2 }, }, diff --git a/packages/tui/test/mini/tool.test.ts b/packages/tui/test/mini/tool.test.ts index fb005cdf13c..62a28c08ce9 100644 --- a/packages/tui/test/mini/tool.test.ts +++ b/packages/tui/test/mini/tool.test.ts @@ -28,7 +28,7 @@ describe("Mini tool presentation", () => { state: { status: "completed", input: { patchText: "*** Begin Patch\n*** End Patch" }, - structured: { + metadata: { files: [ { type: "update", @@ -45,7 +45,7 @@ describe("Mini tool presentation", () => { ).toMatchObject({ name: "patch", state: { - structured: { + metadata: { files: [ { status: "modified", @@ -66,27 +66,25 @@ describe("Mini tool presentation", () => { state: { status: "running", input: { subagent_type: "explore", description: "Inspect" }, - structured: {}, - content: [], + metadata: {}, }, time: { created: 1, ran: 1 }, }), ).toMatchObject({ name: "subagent", state: { input: { agent: "explore" } } }) }) - test("renders the skill name from structured metadata with the input id as fallback", () => { - const skill = (structured: { name?: string }) => ({ - type: "tool" as const, - id: "call-skill", - name: "skill", - state: { - status: "completed" as const, - input: { id: "tigerstyle" }, - structured, - content: [], - }, - time: { created: 1, ran: 1, completed: 2 }, - }) + test("renders the skill name from tool metadata with the input id as fallback", () => { + const skill = (metadata: { name?: string }) => + canonicalToolPart( + "skill", + { + status: "completed", + input: { id: "tigerstyle" }, + metadata, + content: [{ type: "text", text: "" }], + }, + "call-skill", + ) expect(toolInlineInfo(skill({ name: "effect" })).title).toBe('Skill "effect"') expect(toolInlineInfo(skill({})).title).toBe('Skill "tigerstyle"') @@ -112,8 +110,8 @@ describe("Mini tool presentation", () => { canonicalToolPart("glob", { status: "completed", input: { pattern: "*.ts" }, - structured: { count: 3 }, - content: [], + metadata: { count: 3 }, + content: [{ type: "text", text: "" }], }), ).description, ).toBe("3 matches") @@ -122,8 +120,8 @@ describe("Mini tool presentation", () => { canonicalToolPart("grep", { status: "completed", input: { pattern: "needle" }, - structured: { matches: 1 }, - content: [], + metadata: { matches: 1 }, + content: [{ type: "text", text: "" }], }), ).description, ).toBe("1 match") diff --git a/packages/tui/test/util/tool-display.test.ts b/packages/tui/test/util/tool-display.test.ts index 1201dba2579..cedde400c3c 100644 --- a/packages/tui/test/util/tool-display.test.ts +++ b/packages/tui/test/util/tool-display.test.ts @@ -40,19 +40,19 @@ describe("webSearchProviderLabel", () => { }) describe("toolDisplayMetadata", () => { - test("returns structured metadata for non-pending states", () => { - const structured = { provider: "parallel", numResults: 3 } + test("returns tool metadata for non-pending states", () => { + const metadata = { provider: "parallel", numResults: 3 } - expect(toolDisplayMetadata({ status: "running", structured })).toBe(structured) - expect(toolDisplayMetadata({ status: "completed", structured })).toBe(structured) - expect(toolDisplayMetadata({ status: "error", structured })).toBe(structured) + expect(toolDisplayMetadata({ status: "running", metadata })).toBe(metadata) + expect(toolDisplayMetadata({ status: "completed", metadata })).toBe(metadata) + expect(toolDisplayMetadata({ status: "error", metadata })).toBe(metadata) }) test("does not expose pending or malformed metadata", () => { - expect(toolDisplayMetadata({ status: "streaming", structured: { provider: "exa" } })).toEqual({}) + expect(toolDisplayMetadata({ status: "streaming", metadata: { provider: "exa" } })).toEqual({}) expect(toolDisplayMetadata({ status: "completed" })).toEqual({}) - expect(toolDisplayMetadata({ status: "completed", structured: null })).toEqual({}) - expect(toolDisplayMetadata({ status: "completed", structured: [] })).toEqual({}) + expect(toolDisplayMetadata({ status: "completed", metadata: null })).toEqual({}) + expect(toolDisplayMetadata({ status: "completed", metadata: [] })).toEqual({}) expect(toolDisplayMetadata(undefined)).toEqual({}) }) }) diff --git a/packages/www/content/docs/build/plugins.mdx b/packages/www/content/docs/build/plugins.mdx index e5adbc92d76..74c7a6e5e9b 100644 --- a/packages/www/content/docs/build/plugins.mdx +++ b/packages/www/content/docs/build/plugins.mdx @@ -248,7 +248,7 @@ mutable fields: | `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` | | `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch | | `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes | -| `ctx.tool.hook("execute.after", callback)` | `result`, `output`, and `outputPaths`, after execution settles | +| `ctx.tool.hook("execute.after", callback)` | Terminal `content`, `metadata`, and `outputPaths`; `error` on failure | For example, remove a tool from selected model requests and normalize another tool's input: @@ -278,51 +278,66 @@ handle expected errors inside the callback. ### Add a tool -Pass a tool declaration to `tools.add`. Define its input with JSON Schema and -use an async executor: +Create an executable tool with `Tool.make`, then register it with a name +and registration options. Define its input with JSON Schema and use an async +executor: ```js title=".opencode/plugins/greeting.js" import { Plugin } from "@opencode-ai/plugin/v2" +import { Tool } from "@opencode-ai/plugin/v2/tool" export default Plugin.define({ id: "acme.greeting", setup: async (ctx) => { await ctx.tool.transform((tools) => { - tools.add({ - name: "greeting", - description: "Create a greeting", - jsonSchema: { - type: "object", - properties: { - name: { type: "string" }, + tools.add( + "greeting", + Tool.make({ + description: "Create a greeting", + input: { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + additionalProperties: false, }, - required: ["name"], - additionalProperties: false, - }, - execute: async ({ name }) => { - const text = `Hello, ${name}!` - return { - structured: { greeting: text }, - content: [{ type: "text", text }], - } - }, - }) + output: { + type: "object", + properties: { greeting: { type: "string" } }, + required: ["greeting"], + additionalProperties: false, + }, + execute: async ({ name }) => { + const text = `Hello, ${name}!` + return { + output: { greeting: text }, + content: text, + } + }, + }), + ) }) }, }) ``` -Unsupported characters in tool and group names are normalized to underscores. -The resulting exposed key must begin with a letter and contain at most 64 -letters, digits, underscores, or hyphens. Set `options` on the declaration to -configure registration with `{ group, deferred }`: +Unsupported characters in tool names are normalized to underscores. Namespace +segments must begin with a letter, contain at most 64 letters, digits, +underscores, or hyphens, and are joined with dots. Pass the optional third +argument to `tools.add` to configure the registration with +`{ namespace, codemode }`: -- `group` prefixes and groups the exposed tool name. -- `deferred: true` makes the tool available through the deferred `execute` - tool instead of exposing it directly. +- `namespace` prefixes and groups the exposed tool name. +- `codemode` defaults to `true` and makes the tool available through the + `execute` CodeMode tool. Set `codemode: false` to expose it directly to the + provider. The executor receives a second context argument containing `sessionID`, -`agent`, `assistantMessageID`, and `toolCallID`. +`agent`, `messageID`, `callID`, and `progress`. A tool with `output` +must return `output`; Effect and Standard Schema codecs validate it, while raw +JSON Schema definitions enforce JSON compatibility only. A tool +without `output` returns model-visible `content` instead. ### Add a command diff --git a/specs/v2/README.md b/specs/v2/README.md index 49fe529fa6d..33a91df3218 100644 --- a/specs/v2/README.md +++ b/specs/v2/README.md @@ -23,7 +23,7 @@ Generated clients follow the assembled public `HttpApi`. GitHub issues own activ | Document | Job | | ----------------------- | --------------------------------------------------------------------------------------- | | [Session](./session.md) | Explain prompt admission, execution, instructions, compaction, and recovery boundaries. | -| [Tools](./tools.md) | Explain tool construction, registration, execution, and settlement laws. | +| [Tools](./tools.md) | Explain tool construction, registration, execution, and outcome laws. | ## Decisions And Proposals diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 3e760ad00df..1ad9151a011 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -2,6 +2,19 @@ Status: **Historical pre-release compatibility ledger.** Older entries retain the names and behavior that were accurate when written; current contracts live in Protocol, Schema, Core, and the indexed specifications. +## 2026-07-22: Canonical Tool Results + +- Bump `session.tool.success` and `session.tool.failed` to version 2. Success stores exactly non-empty model `content` plus optional JSON `metadata`; failure stores one `error` plus the final bounded partial snapshot (`content?`, `metadata?`). The generic `structured` and `result` fields are removed. +- Rename the ephemeral `session.tool.progress` field `structured` to `metadata`; progress is one metadata replacement snapshot. Model content belongs exclusively to terminal outcomes. +- Change projected `SessionMessage.ToolState`: completed is `{ input, content, metadata? }` with non-empty content, error is `{ input, error, content?, metadata? }`, running renames `structured` to `metadata`. Provider replay derives wire values from canonical content. +- Move provider-hosted result payloads into provider-owned result state (`providerResultState.result`); Anthropic server-tool round-trips read it during lowering. OpenAI continues replaying from item references. +- Public Plugin API: remove `structured`, projection callbacks, the `Structured` generic, `Tool.Failure.metadata`, and the exported `Tool.settle`; tool responses carry schema-validated `output`, model-visible `content`, and optional JSON `metadata`. Code Mode receives the validated encoded output. + +Compatibility: + +- `20260722170000_canonical_tool_results` rewrites projected assistant tool rows in place: terminal content is preserved (or synthesized once from the old `structured`/`result`), compact values move to `metadata` only where tools now declare projections, and hosted payloads are copied into `providerResultState.result`. Old-version tool events fall out of the durable manifest and are skipped on read; no event rows are deleted. +- Promise and Effect client surfaces are regenerated. The legacy JavaScript SDK regenerates on the branch where the V1 package exists. + ## 2026-07-10: Replace Instruction Checkpoints With Value Deltas - Replace rendered `session.instructions.updated.1` prose with `session.instructions.updated.2 { delta }`, where values are SHA-256 hashes and the literal `"removed"` means removal. @@ -74,7 +87,7 @@ Compatibility: - No stored event row, database, or runtime publish behavior change; runtime already attaches the envelope only after durable commit/replay. - Generated clients now model the existing invariant: durable events carry `durable`, live-only events do not. -## 2026-07-03: Declare Event Durability At Definition Level +## 2026-07-03: Declare Event Durability At Tool Level - Add explicit `Event.durable(...)` and `Event.ephemeral(...)` definition constructors. - Preserve the existing durable and live-only event classifications while deriving durable inventories from definition metadata instead of hand-maintained lists. diff --git a/specs/v2/session.md b/specs/v2/session.md index 763d5807197..63dcadcac1e 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -43,13 +43,13 @@ The managed server provides graceful restart continuity through private Session Before each Step, the runner reloads Session History, resolves the selected agent and model, prepares instructions, and materializes tools. Most Steps make one Physical Attempt; overflow-triggered compaction recovery may rebuild the same Step for one additional provider request. -Each complete local tool call is durable before side effects begin. Local calls start eagerly and may run concurrently, but settlement publication remains serialized. Every local and hosted call reaches durable success or failure before the Step publishes its single terminal ended or failed event. +Each complete local tool call is durable before side effects begin. Local calls start eagerly and may run concurrently, but terminal outcome publication remains serialized. Every local and hosted call reaches durable success or failure before the Step publishes its single terminal ended or failed event. Tool calls belong to their assistant message. `callID` is unique only within that Step, so durable tool events also carry `assistantMessageID`. Before `runStep` assembles its provider request, orphan reconciliation fails tool calls still projected as streaming or running from an earlier process. It preserves the original assistant attribution and never replays ambiguous side effects. -After local settlement, continuation reloads projected history and begins a new Step. The runner never delegates orchestration to an in-memory tool loop. +After a local outcome, continuation reloads projected history and begins a new Step. The runner never delegates orchestration to an in-memory tool loop. ## Retry Is Narrow And Observable diff --git a/specs/v2/tools.md b/specs/v2/tools.md index 2b30c019918..592223a20c2 100644 --- a/specs/v2/tools.md +++ b/specs/v2/tools.md @@ -1,28 +1,28 @@ # V2 Tools -Status: **Current semantic overview.** The Plugin package owns the public tool type; Core owns registration, settlement, and generic output bounding. +Status: **Current semantic overview.** The Plugin package owns the public tool type; Core owns registration, execution, and generic output bounding. -## Tool Declarations +## Tools -V2 has one structural declaration for locally executable tools. Typed tools declare schemas, execution, and optional model-facing projection together: +V2 has one structural tool value for locally executable tools. Typed tools declare schemas and execution together: ```ts const read = Tool.make({ description: "Read a file", input: Schema.Struct({ path: Schema.String }), output: Schema.Struct({ content: Schema.String }), - execute: ({ path }, context) => readFile(path, context), - toModelOutput: ({ output }) => [{ type: "text", text: output.content }], + execute: ({ path }, context) => + readFile(path, context).pipe(Effect.map((output) => ({ output, content: output.content }))), }) ``` -`structured` and `toStructuredOutput` may expose a smaller validated result than the complete execution output. Dynamic MCP and manifest tools use the same declaration with runtime JSON Schema. +One tool response may carry three values: the declared, schema-validated `output` is the ephemeral machine value Code Mode receives; `content` is the model-facing value stored durably; and optional `metadata` is compact JSON for tool-specific UI. A tool without `output` intentionally returns only model-visible `content` and optional `metadata`. Dynamic MCP and manifest tools use the same tool shape with runtime JSON Schema. Built-ins and statically authored plugin tools use this same constructor and execution contract. -`Tool.Definition` is a transparent structural value with exactly one executor. Effect schemas and schemas implementing both Standard Schema V1 and Standard JSON Schema V1 are accepted. The Tool module derives model definitions and interprets invocations for the registry; callers normally rely on `Tool.make` inference rather than naming the declaration type. +`Tool.Tool` is a transparent structural value with exactly one `execute` function. Effect schemas and schemas implementing both Standard Schema V1 and Standard JSON Schema V1 are accepted. The Tool module derives inert model-facing `LLM.ToolDefinition` values and executes tools for the registry; callers normally rely on `Tool.make` inference rather than naming the nested type. -Standard input schemas validate model input into the handler value. Standard output schemas validate the handler result into the model-facing value. Effect codecs retain their native decode-input and encode-output directions. +Standard input schemas validate model input into the tool input. Standard output schemas validate the tool response's `output` into the Code Mode machine value. Effect codecs retain their native decode-input and encode-output directions. Input and output codecs are self-contained. Schema conversion cannot require services. Tool dependencies are acquired during construction and captured by `execute`. @@ -34,14 +34,13 @@ Every local tool receives the same concrete invocation context: interface Tool.Context { readonly sessionID: Session.ID readonly agent: Agent.ID - readonly assistantMessageID: SessionMessage.ID - readonly toolCallID: string + readonly messageID: SessionMessage.ID + readonly callID: string + readonly progress: (update: Progress) => Effect.Effect } ``` -`assistantMessageID` is the durable ID of the assistant message containing the call. The Session runner owns this association and supplies the complete context to the registry; the registry does not infer it. - -Durable events call the invocation identifier `callID`; `Tool.Context.toolCallID` is the same value at the executor boundary. +`messageID` is the durable ID of the assistant message containing the call. The Session runner owns this association and supplies the complete context to the registry; the registry does not infer it. `callID` carries the same invocation identifier durable events use. Decoded tool input is passed separately to `execute`. Raw provider input and domain services do not belong in the invocation context. @@ -65,7 +64,8 @@ The record key is the authored name. Registration normalizes it before deriving ```ts interface Tools { readonly register: ( - tools: Readonly>, + tools: Readonly>, + options?: Tool.RegisterOptions, ) => Effect.Effect } ``` @@ -105,8 +105,8 @@ yield * agent: context.agent, source: { type: "tool", - messageID: context.assistantMessageID, - callID: context.toolCallID, + messageID: context.messageID, + callID: context.callID, }, action: "grep", resources: [input.pattern], @@ -126,30 +126,32 @@ Sharing a tool type does not imply equal authority. Built-ins and trusted Locati ## Requests Capture Tool Values -The Location-scoped registry owns effective lookup and settlement. For each local call it: +The Location-scoped registry owns effective lookup and execution through one request-scoped snapshot pairing advertised LLM definitions with captured tools. For each local call it: 1. Resolves one effective named registration. 2. Decodes provider input with the input codec. -3. Invokes the tool with the runner-supplied context. -4. Encodes the returned output with the output codec. -5. Projects encoded output into model-facing content. -6. Bounds the complete model-facing output. -7. Runs `execute.after` hooks with the bounded settlement. -8. Returns the settlement to the runner for durable publication. +3. Executes the tool with the runner-supplied context. +4. Encodes the returned output with the output codec; the encoded value is the ephemeral machine output for Code Mode. +5. Normalizes the tool response into canonical non-empty model content and optional JSON metadata. +6. Bounds the model content; validates metadata, dropping invalid or oversized values with a warning rather than failing the call. +7. Runs `execute.after` hooks with the canonical outcome and managed output paths. +8. Returns one `ToolOutcome` — completed with output, content, and optional metadata, or an error with an optional final partial snapshot — to the runner for durable publication. -Invalid input never invokes the tool. Invalid output never produces a successful settlement. +Invalid input never executes the tool. Invalid output never produces a successful execution. -`toModelOutput` is pure and total. When omitted, the encoded output remains structured output; an encoded string is also projected as text. Projection does not receive invocation identity because presentation depends only on validated input and output. +When an output-bearing tool omits `content`, an encoded string becomes one text item and any other encoded JSON is serialized once. A tool without `output` must provide non-empty model content. -Each model request captures the effective registered `Tool` value for every advertised name. Settlement executes those captured values; later registration changes affect later requests. +Each model request captures the effective registration for every advertised name. Execution uses those captured tools; later registration changes affect later requests. Unknown, hook-removed, and final-Step calls fail individually through the same execution seam; the final Step retains tool definitions with `toolChoice: "none"` where the provider supports it so the cached prompt prefix survives. + +Durable terminal events are self-contained: success stores exactly the non-empty model content plus optional metadata; failure stores one error plus the final bounded snapshot of partial progress. Provider replay derives its wire value from canonical content; provider-hosted payloads that a protocol requires verbatim live in provider-owned result state, never in a generic result field. ## Producers And The Registry Own Different Limits Producers may cap capture or spool data before a complete tool result exists. For example, a process tool may retain output it cannot keep in memory. Producer limits must report their own loss accurately; they are separate from registry bounding and cannot claim to reconstruct bytes already discarded. -After projection, the registry bounds the channel sent to the provider. When content exists, only its textual parts are measured; structured metadata is retained unchanged without being double-counted, and native media remains unchanged under producer-owned limits. When content is empty, the structured output is measured. Oversized provider-facing text or structured output is retained in managed storage and replaced with a bounded text preview while structured metadata and media are preserved; if complete retention fails, settlement fails operationally rather than publishing lossy success. Managed paths never appear in `Tool.make`, tool output schemas, or projection callbacks solely for retention bookkeeping. +After tool execution, the registry bounds the model content sent to the provider: only textual parts are measured, native media remains unchanged under producer-owned limits, and the default cut keeps a head-plus-tail split with the omission marker in the middle. Oversized text is retained in managed storage and replaced with a bounded preview; if complete retention fails, execution fails operationally rather than publishing lossy success. Metadata is validated and measured independently and never becomes an unbounded side channel. Managed paths never appear in `Tool.make` or tool output schemas solely for retention bookkeeping. -`execute.after` hooks receive the bounded settlement and its internal managed paths. Hooks may deliberately transform that settlement; the registry does not apply a second bounding pass afterward. +`execute.after` hooks receive the canonical bounded outcome and its internal managed paths. Hooks may deliberately transform that outcome; changed content is normalized and bounded again before publication. ## Failures Preserve Interruptions @@ -158,15 +160,18 @@ Outcomes remain distinct: - `ToolFailure` is an expected model-visible failure. - Interruption cancels the invocation and is not a tool result. - Unexpected typed errors and defects follow the runner's operational failure policy. -- Unknown and invalid calls become explicit model-visible settlement errors without invoking a handler. +- Unknown and invalid calls become explicit model-visible execution errors without executing a tool. -Leaf tools translate only errors they deliberately classify as recoverable. Broad cause-catching around an executor is invalid because it consumes interruption and defects. +Tools translate only errors they deliberately classify as recoverable. Broad cause-catching around `execute` is invalid because it consumes interruption and defects. ## Laws -- **Single executor:** `Tool.make(config)` can invoke only `config.execute`. -- **Codec boundary:** execution observes decoded input; projection observes encoded output. +- **Single execution:** `Tool.make(config)` can execute only `config.execute`. +- **Codec boundary:** a tool observes decoded input; Code Mode observes the validated encoded output; model content and metadata come from the tool response. +- **Canonical representation:** a completed call has exactly one stored model representation; a failed call has exactly one stored error plus at most one final partial snapshot. Every other view is derived at a named boundary. +- **Metadata opt-in:** absent response metadata produces absent metadata, never a copied output. - **Durable identity:** invocation-owned records use the exact Session, agent, assistant message, and call IDs supplied by the runner. - **Scoped registration:** closing a Scope removes exactly its registration and reveals any prior active overlay. -- **Captured execution:** a call executes the registered `Tool` value advertised in its model request. +- **Captured execution:** a call executes the registered tool advertised in its model request. +- **Per-call rejection:** rejecting one unavailable call cannot fail another call. - **Storage encapsulation:** domain output does not change according to model-output bounding or retention policy. From 02f27251540efeb414dd526f227a27c78000841a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:20:37 -0500 Subject: [PATCH 29/30] chore: merge dev into v2 (#38563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Aiden Cline Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Dax Raad Co-authored-by: Dax Co-authored-by: opencode-agent[bot] Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Nabs Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan Co-authored-by: Victor Navarro Co-authored-by: Vladimir Glafirov Co-authored-by: AidenGeunGeun Co-authored-by: Mark Co-authored-by: Aiden Cline Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: opencode Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Jay Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com> Co-authored-by: Dustin Deus Co-authored-by: Frank Co-authored-by: Jack Co-authored-by: Sebastian Co-authored-by: Jérôme Benoit Co-authored-by: Test User Co-authored-by: Simon Klee Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Co-authored-by: Qiping Li Co-authored-by: liqiping Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com> Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com> Co-authored-by: Daniel Polito Co-authored-by: opencode --- bun.lock | 21 +- nix/hashes.json | 8 +- package.json | 1 + .../performance/timeline-stability/fixture.ts | 2 + .../session-timeline-transport.spec.ts | 6 +- packages/app/e2e/utils/mock-server.ts | 278 ++++++- packages/app/e2e/utils/sse-transport.ts | 38 +- packages/app/package.json | 1 + packages/app/src/context/server-sdk.test.ts | 38 +- packages/app/src/context/server-sdk.tsx | 194 +++-- packages/app/src/utils/server-compat.test.ts | 113 +++ packages/app/src/utils/server-compat.ts | 495 ++++++++++++ packages/app/src/utils/server-health.test.ts | 38 +- packages/app/src/utils/server-health.ts | 28 +- .../app/src/utils/server-protocol.test.ts | 40 + packages/app/src/utils/server-protocol.ts | 35 + packages/app/src/utils/server.ts | 21 + .../app/vendor/opencode-ai-client-1.17.13.tgz | Bin 0 -> 75585 bytes packages/core/package.json | 2 +- packages/core/test/provider-mistral.test.ts | 282 +++++++ packages/desktop/src/main/server.ts | 23 +- packages/session-ui/package.json | 1 + patches/@ai-sdk%2Fmistral@3.0.51.patch | 709 ++++++++++++++++++ 23 files changed, 2280 insertions(+), 94 deletions(-) create mode 100644 packages/app/src/utils/server-compat.test.ts create mode 100644 packages/app/src/utils/server-compat.ts create mode 100644 packages/app/src/utils/server-protocol.test.ts create mode 100644 packages/app/src/utils/server-protocol.ts create mode 100644 packages/app/vendor/opencode-ai-client-1.17.13.tgz create mode 100644 packages/core/test/provider-mistral.test.ts create mode 100644 patches/@ai-sdk%2Fmistral@3.0.51.patch diff --git a/bun.lock b/bun.lock index 24e3c97c4ed..fb36652e940 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -350,7 +351,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/mistral": "3.0.51", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", @@ -470,7 +471,9 @@ "packages/docs": { "name": "@opencode-ai/docs", "devDependencies": { + "effect": "catalog:", "mint": "4.2.666", + "prettier": "3.6.2", }, }, "packages/effect-drizzle-sqlite": { @@ -706,6 +709,7 @@ "version": "1.18.4", "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -1079,6 +1083,7 @@ "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", @@ -1205,7 +1210,7 @@ "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], - "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], + "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], @@ -6151,7 +6156,9 @@ "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], + + "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -6581,6 +6588,8 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="], + "@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="], @@ -6599,6 +6608,8 @@ "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="], + "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], @@ -6927,6 +6938,8 @@ "aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], + "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -8047,6 +8060,8 @@ "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], diff --git a/nix/hashes.json b/nix/hashes.json index 963d46ecf49..407d7812fb2 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=", - "aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=", - "aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=", - "x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo=" + "x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=", + "aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=", + "aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=", + "x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ=" } } diff --git a/package.json b/package.json index 2110c4d9af0..6332861c915 100644 --- a/package.json +++ b/package.json @@ -160,6 +160,7 @@ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 5095d95db02..df67da5a662 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -97,6 +97,7 @@ export async function setupTimeline( locale?: string deviceScaleFactor?: number seedHistory?: boolean + protocol?: "v1" | "v2" } = {}, ) { const sessions = input.sessions ?? [session()] @@ -114,6 +115,7 @@ export async function setupTimeline( retry: input.eventRetry ?? 20, }) await mockOpenCodeServer(page, { + protocol: input.protocol, directory, project: project(), provider: provider(), diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 850e966d0b0..778ff3a3af9 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => { expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") }) -test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) +test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { + const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { id: "timeline-event-7", }) @@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) = const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) expect(first.eventID).toBe("timeline-event-7") - expect(connection.headers["last-event-id"]).toBe("timeline-event-7") + expect(connection.headers["last-event-id"]).toBeUndefined() }) test("passes through non-event fetches", async ({ page }) => { diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 34c60ba7f4d..5cca83895f5 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -4,6 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) export interface MockServerConfig { + protocol?: "v1" | "v2" provider: unknown directory: string project: unknown @@ -53,8 +54,20 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname - if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) - if (path === "/global/health") return json(route, { healthy: true }) + if (path === "/global/event" || path === "/event" || path === "/api/event") { + const events = config.events?.() + return sse( + route, + path === "/api/event" + ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] + : events, + config.eventRetry, + ) + } + if (path === "/global/health") + return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) + if (path === "/api/health" && config.protocol === "v2") + return json(route, { healthy: true, version: "2.0.0", pid: 1 }) if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) @@ -83,10 +96,129 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }, data: [], }) + if (path === "/api/agent") + return json(route, { + location: location(config), + data: [ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + request: { settings: {}, headers: {}, body: {} }, + permissions: [], + }, + ], + }) + if (path === "/api/command") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp/resource") + return json(route, { location: location(config), data: { resources: [], templates: [] } }) + const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1] + if (integration && route.request().method() === "GET") + return json(route, { + location: location(config), + data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] }, + }) + if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST") + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + if (path === "/api/project") return json(route, [config.project]) + if (path === "/api/project/current") + return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) + if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project) + if (path === "/api/path") + return json(route, { + state: config.directory, + config: config.directory, + worktree: config.directory, + directory: config.directory, + home: "C:/OpenCode", + }) + if (path === "/api/permission/request") + return json(route, { + location: location(config), + data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( + currentPermission, + ), + }) + if (path === "/api/question/request") + return json(route, { + location: location(config), + data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), + }) + if (path === "/api/vcs") + return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) + if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) + if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) + if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) + if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) + return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) + if (path === "/api/session") { + const directory = url.searchParams.get("directory") + const parentID = url.searchParams.get("parentID") + const limit = Number(url.searchParams.get("limit") ?? 50) + const offset = Number(url.searchParams.get("cursor") ?? 0) + const sessions = config.sessions + .filter((session) => !directory || session.directory === directory) + .filter((session) => parentID !== "null" || session.parentID === undefined) + .filter((session) => { + const search = url.searchParams.get("search")?.toLowerCase() + return ( + !search || + String(session.title ?? "") + .toLowerCase() + .includes(search) + ) + }) + const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions + const data = ordered.slice(offset, offset + limit) + const next = offset + limit < ordered.length ? String(offset + limit) : undefined + return json(route, { + data: data.map((session) => currentSession(session, config.directory)), + cursor: { next }, + }) + } + if (path === "/api/session/active") { + const statuses = (config.sessionStatus ?? {}) as Record + return json(route, { + data: Object.fromEntries( + Object.entries(statuses).flatMap(([id, status]) => + status.type === "idle" ? [] : [[id, { type: "running" }]], + ), + ), + }) + } + if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if ( + /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && + route.request().method() === "POST" + ) { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } if (path in staticRoutes) return json(route, staticRoutes[path]) + const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) + if (currentSessionMatch) { + const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) + if (!session) return json(route, { error: "Session not found" }, undefined, 404) + return json(route, { + data: currentSession(session, config.directory), + }) + } + const sessionMatch = path.match(/^\/session\/([^/]+)$/) if (sessionMatch) { const session = config.sessions.find((s) => s.id === sessionMatch[1]) @@ -107,6 +239,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) + const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) + if (currentMessagesMatch) { + const token = url.searchParams.get("cursor") ?? undefined + const before = token ? cursors.get(token) : undefined + if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" }) + await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" }) + const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined + if (cursor) cursors.set(cursor, pageData.cursor!) + return json(route, { + data: pageData.items.map(currentMessage).reverse(), + cursor: { next: cursor }, + }) + } + const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { const token = url.searchParams.get("before") ?? undefined @@ -129,6 +279,115 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } +function location(config: MockServerConfig) { + return { + directory: config.directory, + project: { id: (config.project as { id?: string }).id, directory: config.directory }, + } +} + +function currentPermission(value: unknown) { + const permission = value as Record + if (permission.action) return permission + const tool = permission.tool as { messageID?: string; callID?: string } | undefined + return { + id: permission.id, + sessionID: permission.sessionID, + action: permission.permission, + resources: permission.patterns ?? [], + save: permission.always, + metadata: permission.metadata, + source: + tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, + } +} + +export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { + const time = session.time && typeof session.time === "object" ? session.time : {} + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID ?? "project", + agent: session.agent ?? "build", + model: session.model ?? { id: "mock-model", providerID: "mock-provider" }, + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { + created: "created" in time && typeof time.created === "number" ? time.created : 0, + updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0, + ...(session.time && typeof session.time === "object" && "archived" in session.time + ? { archived: session.time.archived } + : {}), + }, + title: session.title ?? session.id, + location: { + directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, + ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), + }, + subpath: session.path, + revert: session.revert, + } +} + +function currentMessage(value: unknown) { + const item = value as { + info: Record & { id: string; role: "user" | "assistant"; time: { created: number } } + parts: Array & { type: string }> + } + if (item.info.role === "user") { + return { + id: item.info.id, + type: "user", + time: item.info.time, + text: item.parts + .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) + .join("\n"), + } + } + return { + id: item.info.id, + type: "assistant", + time: item.info.time, + agent: item.info.agent ?? "build", + model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" }, + cost: item.info.cost, + tokens: item.info.tokens, + error: item.info.error, + content: item.parts.flatMap((part) => { + if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }] + if (part.type !== "tool") return [] + const state = part.state as Record + return [ + { + type: "tool", + id: part.id, + name: part.tool, + time: state.time ?? { created: item.info.time.created }, + state: + state.status === "pending" + ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) } + : state.status === "completed" + ? { + status: "completed", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [{ type: "text", text: state.output ?? "" }], + } + : state.status === "error" + ? { + status: "error", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [], + error: { type: "ToolError", message: state.error ?? "Tool failed" }, + } + : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] }, + }, + ] + }), + } +} + function json(route: Route, body: unknown, headers?: Record, status = 200) { return route.fulfill({ status, @@ -149,3 +408,18 @@ function sse(route: Route, events?: unknown[], retry?: number) { body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, }) } + +function currentEvent(input: unknown) { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } +} diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 55420485f39..15c3577279f 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -3,7 +3,7 @@ import type { Page } from "@playwright/test" export type SseConnectionRecord = { id: number url: string - path: "/global/event" | "/event" + path: "/global/event" | "/event" | "/api/event" headers: Record openedAt: number endedAt?: number @@ -93,6 +93,21 @@ export async function installSseTransport( eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, `data: ${JSON.stringify(payload)}\n\n`, ].join("") + const currentEvent = (input: unknown) => { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: + envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } + } const acknowledge = ( connection: Connection, bytes: number, @@ -140,15 +155,13 @@ export async function installSseTransport( output.forEach((chunk) => connection.controller.enqueue(chunk)) return acknowledge(connection, input.bytes.length, output.length) } - const encoded = input.deliveries.map((delivery) => ({ - delivery, - bytes: encoder.encode(frame(delivery.payload, delivery.options)), - })) + const encoded = input.deliveries.map((delivery) => { + const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload + return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) } + }) encoded.forEach((item) => marker(item.delivery.options?.marker)) if (input.burst) { - const bytes = encoder.encode( - encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""), - ) + const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) connection.controller.enqueue(bytes) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) } @@ -161,7 +174,10 @@ export async function installSseTransport( const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const url = new URL(request.url) - if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) + if ( + url.origin !== server || + (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") + ) return originalFetch(request) const id = ++nextConnectionID @@ -177,6 +193,10 @@ export async function installSseTransport( record.controller = controller connections.push(record) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) + if (url.pathname === "/api/event") + controller.enqueue( + encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), + ) request.signal.addEventListener( "abort", () => { diff --git a/packages/app/package.json b/packages/app/package.json index faf01944612..f6a1bf9d2ea 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -53,6 +53,7 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 052faf496a5..767d16ffa05 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" describe("resumeStreamAfterPageShow", () => { @@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => { }) }) +describe("adaptServerEvent", () => { + test("preserves V2 events while adapting permission requests for existing consumers", () => { + const current = { + id: "evt_1", + created: 1, + type: "permission.v2.asked", + data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] }, + } as OpenCodeEvent + + expect(adaptServerEvent(current)).toMatchObject({ + type: "permission.asked", + properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] }, + current, + }) + }) +}) + describe("coalesceServerEvents", () => { const delta = (value: string, field = "text", partID = "part") => ({ directory: "/repo", @@ -34,6 +52,24 @@ describe("coalesceServerEvents", () => { expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) }) + test("merges adjacent current text deltas", () => { + const current = (id: string, value: string) => + adaptServerEvent({ + id, + created: 1, + type: "session.text.delta", + location: { directory: "/repo" }, + data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value }, + } as OpenCodeEvent) + const result = coalesceServerEvents([ + { directory: "/repo", payload: current("evt_1", "hello ") }, + { directory: "/repo", payload: current("evt_2", "world") }, + ]) + + expect(result).toHaveLength(1) + expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } }) + }) + test("preserves event boundaries and distinct fields", () => { const status = { directory: "/repo", diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 06597e56e7b..62c58577948 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -1,21 +1,60 @@ +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" -import { createSdkForServer } from "@/utils/server" +import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" +import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" +import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true -type QueuedServerEvent = { directory: string; payload: Event } +export type ServerEvent = Event & { current?: OpenCodeEvent } +type QueuedServerEvent = { directory: string; payload: ServerEvent } +type CurrentDelta = Extract< + OpenCodeEvent, + { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" } +> + +export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { + if (event.type === "permission.v2.asked") { + return { + id: event.id, + type: "permission.asked", + properties: { + id: event.data.id, + sessionID: event.data.sessionID, + permission: event.data.action, + patterns: event.data.resources, + always: event.data.save ?? [], + metadata: event.data.metadata ?? {}, + tool: + event.data.source?.type === "tool" + ? { messageID: event.data.source.messageID, callID: event.data.source.callID } + : undefined, + }, + current: event, + } as ServerEvent + } + if (event.type === "permission.v2.replied") + return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.asked") + return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.replied") + return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.rejected") + return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent + return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent +} const coalescedKey = (event: QueuedServerEvent) => { if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` @@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ export function coalesceServerEvents(events: QueuedServerEvent[]) { const output: QueuedServerEvent[] = [] events.forEach((event) => { + const current = currentDelta(event.payload.current) + if (current) { + const previous = output[output.length - 1] + const prior = currentDelta(previous?.payload.current) + if ( + previous && + prior && + previous.directory === event.directory && + currentDeltaKey(prior) === currentDeltaKey(current) + ) { + const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current) + const data = + current.type === "session.compaction.delta" + ? { ...current.data, text: fragment } + : { ...current.data, delta: fragment } + output[output.length - 1] = { + directory: event.directory, + payload: { + ...event.payload, + properties: data, + current: { ...current, data } as CurrentDelta, + } as ServerEvent, + } + return + } + output.push(event) + return + } if (event.payload.type !== "message.part.delta") { output.push(event) return @@ -71,12 +138,52 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) { return output } +function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { + if ( + event?.type === "session.text.delta" || + event?.type === "session.reasoning.delta" || + event?.type === "session.tool.input.delta" || + event?.type === "session.compaction.delta" + ) + return event +} + +function currentDeltaKey(event: CurrentDelta) { + if (event.type === "session.tool.input.delta") + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}` + if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}` + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}` +} + +function currentDeltaFragment(event: CurrentDelta) { + return event.type === "session.compaction.delta" ? event.data.text : event.data.delta +} + export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { if (!event.persisted) return start() } -function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) { +type ServerEventEmitter = ReturnType> +type ServerSDKBase = { + server: ServerConnection.Any + scope: ServerScope + protocol: Promise + url: string + client: ReturnType + api: CompatibleApi + currentApi: ServerApi + event: { + on: ServerEventEmitter["on"] + listen: ServerEventEmitter["listen"] + start: () => Promise | undefined + } + createClient: ( + opts: Omit[0], "server" | "fetch">, + ) => ReturnType +} + +function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { const platform = usePlatform() const abort = new AbortController() @@ -91,13 +198,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } })() + const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) const eventSdk = createSdkForServer({ signal: abort.signal, fetch: eventFetch, server: server.http, }) + const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch) const emitter = createGlobalEmitter<{ - [key: string]: Event + [key: string]: ServerEvent }>() type Queued = QueuedServerEvent @@ -142,21 +251,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS let run: Promise | undefined let started = false let generation = 0 - const HEARTBEAT_TIMEOUT_MS = 15_000 - let lastEventAt = Date.now() - let heartbeat: ReturnType | undefined - const resetHeartbeat = () => { - lastEventAt = Date.now() - if (heartbeat) clearTimeout(heartbeat) - heartbeat = setTimeout(() => { - attempt?.abort() - }, HEARTBEAT_TIMEOUT_MS) - } - const clearHeartbeat = () => { - if (!heartbeat) return - clearTimeout(heartbeat) - heartbeat = undefined - } const start = () => { if (started) return run @@ -168,35 +262,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started && generation === active) { attempt = new AbortController() - lastEventAt = Date.now() const onAbort = () => { attempt?.abort() } abort.signal.addEventListener("abort", onAbort) try { - const events = await eventSdk.global.event({ - signal: attempt.signal, - onSseError: (error) => { - if (isStreamClosed(error, attempt?.signal)) return - if (streamErrorLogged) return - streamErrorLogged = true - console.error("[global-sdk] event stream error", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) - }, - }) + const kind = await protocol + const events = + kind === "v1" + ? (await eventSdk.global.event({ signal: attempt.signal })).stream + : eventApi.event.subscribe({ signal: attempt.signal }) let yielded = Date.now() - resetHeartbeat() - for await (const event of events.stream) { - resetHeartbeat() + for await (const event of events) { streamErrorLogged = false - if (event.payload.type !== "sync") { - const directory = event.directory ?? "global" - const payload = event.payload as Event - if (enqueueServerEvent(queue, { directory, payload })) schedule() - } + const legacy = "payload" in event + if (legacy && event.payload.type === "sync") continue + const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") + const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) + if (enqueueServerEvent(queue, { directory, payload })) schedule() if (Date.now() - yielded < STREAM_YIELD_MS) continue yielded = Date.now() @@ -214,7 +297,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } finally { abort.signal.removeEventListener("abort", onAbort) attempt = undefined - clearHeartbeat() } if (abort.signal.aborted || !started || generation !== active) return @@ -233,18 +315,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS started = false generation++ attempt?.abort() - clearHeartbeat() } onMount(() => { makeEventListener(window, "pagehide", stop) makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) - makeEventListener(document, "visibilitychange", () => { - if (document.visibilityState !== "visible") return - if (!started) return - if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return - attempt?.abort() - }) }) onCleanup(() => { @@ -258,12 +333,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS fetch: platform.fetch, throwOnError: true, }) + const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch }) + const legacy = (directory?: string) => + createSdkForServer({ + server: server.http, + fetch: platform.fetch, + throwOnError: true, + directory, + }) + const api = createCompatibleApi({ protocol, current: currentApi, legacy }) return { server, scope, + protocol, url: server.http.url, client: sdk, + api, + currentApi, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), @@ -279,7 +366,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } } -type ServerSDKBase = ReturnType export type ServerSDK = ServerSDKBase & { ensureDirSdkContext: (directory: string) => ReturnType } @@ -309,7 +395,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo }) type SDKEventMap = { - [key in Event["type"]]: Extract + [key in Event["type"]]: Extract } function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { @@ -329,6 +415,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { scope: serverSDK.scope, directory, client, + api: createCompatibleApi({ + protocol: serverSDK.protocol, + current: serverSDK.currentApi, + legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), + directory, + }), event: emitter, get url() { return serverSDK.url diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts new file mode 100644 index 00000000000..4907eb41eb1 --- /dev/null +++ b/packages/app/src/utils/server-compat.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test" +import { createApiForServer, createSdkForServer } from "./server" +import { createCompatibleApi } from "./server-compat" + +function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) { + const requests: Request[] = [] + const fetcher = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init) + requests.push(request) + if (request.method === "PATCH") { + return Response.json({ + id: "ses_1", + slug: "ses_1", + projectID: "project", + directory: "/repo", + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + }) + } + if (request.method === "POST" && request.url.endsWith("/prompt_async")) + return new Response(undefined, { status: 204 }) + if (request.method === "POST" && request.url.endsWith("/prompt")) { + return Response.json({ + admittedSeq: 1, + id: "msg_1", + sessionID: "ses_1", + timeCreated: 1, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }) + } + if (request.method === "GET") return Response.json([]) + return new Response(undefined, { status: 204 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const server = { url: "http://localhost:4096" } + const api = createCompatibleApi({ + protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol, + current: createApiForServer({ server, fetch: fetcher }), + legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }), + directory: "/repo", + }) + return { api, requests } +} + +describe("createCompatibleApi", () => { + test("routes V1 archive through the legacy session update", async () => { + const { api, requests } = setup("v1") + await api.session.archive({ sessionID: "ses_1", directory: "/repo" }) + + const url = new URL(requests[0]!.url) + expect(url.pathname).toBe("/session/ses_1") + expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[0]!.method).toBe("PATCH") + expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } }) + }) + + test("converts current prompts to the V1 prompt contract", async () => { + const { api, requests } = setup("v1") + await api.session.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "hello", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + }) + + expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async") + expect(await requests[0]!.json()).toMatchObject({ + messageID: "msg_1", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + parts: [{ type: "text", text: "hello" }], + }) + }) + + test("keeps V2 session actions on the current API", async () => { + const { api, requests } = setup("v2") + await api.session.archive({ sessionID: "ses_1" }) + + expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive") + expect(requests[0]!.method).toBe("POST") + }) + + test("resolves protocol detection once across implementation methods", async () => { + let detections = 0 + const resolved = Promise.resolve<"v1" | "v2">("v2") + const protocol = new Proxy(resolved, { + get(target, property) { + if (property !== "then") return Reflect.get(target, property, target) + detections++ + return target.then.bind(target) + }, + }) + const { api } = setup(protocol) + + await api.session.archive({ sessionID: "ses_1" }) + await api.session.list() + + expect(detections).toBe(1) + }) + + test("uses the global V1 session search endpoint", async () => { + const { api, requests } = setup("v1") + await api.session.list({ parentID: null, search: "session", limit: 50 }) + + expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session") + }) +}) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts new file mode 100644 index 00000000000..17725169007 --- /dev/null +++ b/packages/app/src/utils/server-compat.ts @@ -0,0 +1,495 @@ +import type { ServerApi } from "./server" +import type { ServerProtocol } from "./server-protocol" +import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client" +import type { + Project, + ProjectCurrent, + SessionApi, + SessionCommandInput, + SessionCommandOutput, + SessionCompactInput, + SessionCompactOutput, + SessionInfo, + SessionPromptInput, + SessionPromptOutput, + SessionShellInput, + SessionShellOutput, +} from "@opencode-ai/client/promise" + +type LegacyClient = OpencodeClient +type LegacyFor = (directory?: string) => LegacyClient +type CompatibleSessionApi = Omit< + SessionApi, + "prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove" +> & { + prompt: (input: SessionPromptInput & LegacyPrompt) => Promise + command: (input: SessionCommandInput) => Promise + shell: (input: SessionShellInput & LegacyPrompt) => Promise + compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise + rename: (input: Parameters[0] & LegacyLocation) => ReturnType + archive: (input: Parameters[0] & LegacyLocation) => ReturnType + remove: (input: Parameters[0] & LegacyLocation) => ReturnType +} +export type CompatibleApi = Omit & { readonly session: CompatibleSessionApi } +type LegacyPrompt = { + agent?: string + model?: { providerID: string; modelID: string } + variant?: string +} +type LegacyLocation = { directory?: string } +type CompatibleInput = { + protocol: Promise + current: ServerApi + legacy: LegacyFor + directory?: string +} + +function mime(uri: string) { + const match = /^data:([^;,]+)/.exec(uri) + return match?.[1] ?? "application/octet-stream" +} + +function sessionInfo(session: Session): SessionInfo { + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID, + agent: session.agent, + model: session.model && { + id: session.model.id, + providerID: session.model.providerID, + variant: session.model.variant, + }, + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: session.time, + title: session.title, + location: { directory: session.directory, workspaceID: session.workspaceID }, + subpath: session.path, + revert: session.revert && { + messageID: session.revert.messageID, + partID: session.revert.partID, + snapshot: session.revert.snapshot, + }, + } +} + +export function createCompatibleApi(input: CompatibleInput): CompatibleApi { + const v1 = createV1Api(input) + return lazyApi( + input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)), + input.current, + ) +} + +function lazyApi(implementation: Promise, shape: T): T { + const cache = new Map() + return new Proxy(shape, { + get(target, property, receiver) { + const sample = Reflect.get(target, property, receiver) + if (typeof sample === "function") { + return (...args: unknown[]) => + implementation.then((value) => { + const method = Reflect.get(value, property) + if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`) + return Reflect.apply(method, value, args) + }) + } + if (sample === null || typeof sample !== "object") return sample + if (cache.has(property)) return cache.get(property) + const nested = lazyApi( + implementation.then((value) => { + const result = Reflect.get(value, property) + if (result === null || typeof result !== "object") { + throw new Error(`API namespace unavailable: ${String(property)}`) + } + return result + }), + sample, + ) + cache.set(property, nested) + return nested + }, + }) +} + +function createV1Api(input: CompatibleInput): CompatibleApi { + const directory = (location?: { directory?: string }) => location?.directory ?? input.directory + const legacy = (location?: { directory?: string }) => input.legacy(directory(location)) + const located = (data: T, value?: { directory?: string }) => ({ + location: { + directory: directory(value) ?? "", + project: { id: "", directory: directory(value) ?? "" }, + }, + data, + }) + + return { + ...input.current, + session: { + ...input.current.session, + async list( + value?: Parameters[0], + options?: Parameters[1], + ) { + if (!value?.directory && value?.search !== undefined) { + const result = await legacy().experimental.session.list( + { + roots: value.parentID === null ? true : undefined, + search: value.search, + limit: value.limit, + }, + options, + ) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + } + const result = await legacy({ directory: value?.directory }).session.list({ + directory: value?.directory, + roots: value?.parentID === null ? true : undefined, + search: value?.search, + limit: value?.limit, + }) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + }, + async create(value?: Parameters[0]) { + const result = await legacy(value?.location ?? undefined).session.create({ + directory: directory(value?.location ?? undefined), + }) + if (!result.data) throw new Error("Failed to create session") + return sessionInfo(result.data) + }, + async get(value: Parameters[0]) { + const result = await legacy().session.get(value) + if (!result.data) throw new Error(`Session not found: ${value.sessionID}`) + return sessionInfo(result.data) + }, + async active() { + const result = await legacy().session.status() + return Object.fromEntries( + Object.entries(result.data ?? {}).flatMap(([sessionID, status]) => + status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], + ), + ) + }, + async rename(value: Parameters[0] & LegacyLocation) { + await legacy(value).session.update({ sessionID: value.sessionID, title: value.title }) + }, + async archive(value: Parameters[0] & LegacyLocation) { + await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } }) + }, + async remove(value: Parameters[0] & LegacyLocation) { + await legacy(value).session.delete(value) + }, + async fork(value: Parameters[0]) { + const result = await legacy().session.fork(value) + if (!result.data) throw new Error("Failed to fork session") + return sessionInfo(result.data) + }, + async interrupt(value: Parameters[0]) { + await legacy().session.abort(value) + }, + async prompt(value: SessionPromptInput & LegacyPrompt) { + await legacy().session.promptAsync({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + agent: value.agent, + model: value.model, + variant: value.variant, + parts: [ + { type: "text", text: value.text }, + ...(value.files ?? []).map((file) => ({ + type: "file" as const, + mime: mime(file.uri), + url: file.uri, + filename: file.name, + })), + ...(value.agents ?? []).map((agent) => ({ + type: "agent" as const, + name: agent.name, + source: agent.mention + ? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end } + : undefined, + })), + ], + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: value.text }, + delivery: value.delivery ?? "steer", + } + }, + async command(value: SessionCommandInput) { + await legacy().session.command({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + command: value.command, + arguments: value.arguments ?? "", + agent: value.agent ?? undefined, + model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined, + variant: value.model?.variant, + parts: value.files?.map((file) => ({ + type: "file" as const, + mime: mime(file.uri), + url: file.uri, + filename: file.name, + })), + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() }, + delivery: value.delivery ?? "steer", + } + }, + async shell(value: SessionShellInput & LegacyPrompt) { + await legacy().session.shell({ + sessionID: value.sessionID, + command: value.command, + agent: value.agent, + model: value.model, + }) + }, + compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => { + if (!value.model) throw new Error("A model is required to compact a V1 session") + await legacy().session.summarize({ + sessionID: value.sessionID, + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "compaction", + } + }, + revert: { + stage: async (value: Parameters[0]) => { + await legacy().session.revert(value) + return { messageID: value.messageID } + }, + clear: async (value: Parameters[0]) => { + await legacy().session.unrevert(value) + }, + commit: input.current.session.revert.commit, + }, + }, + project: { + ...input.current.project, + async list() { + return ((await legacy().project.list()).data ?? []) as Project[] + }, + async current(value?: Parameters[0]) { + const result = await legacy(value?.location).project.current() + if (!result.data) throw new Error("Project not found") + return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent + }, + async update(value: Parameters[0]) { + const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) + const result = await legacy({ directory: project?.worktree }).project.update({ + ...value, + directory: project?.worktree, + }) + if (!result.data) throw new Error(`Project not found: ${value.projectID}`) + return result.data as Project + }, + async directories(value: Parameters[0]) { + const result = await legacy(value.location).worktree.list() + return (result.data ?? []).map((item) => ({ directory: item })) + }, + }, + path: { + ...input.current.path, + async get(value?: Parameters[0]) { + const result = await legacy(value?.location).path.get() + if (!result.data) throw new Error("Path unavailable") + return result.data + }, + }, + vcs: { + ...input.current.vcs, + async get(value?: Parameters[0]) { + const result = await legacy(value?.location).vcs.get() + return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location) + }, + async status(value?: Parameters[0]) { + const result = await legacy(value?.location).vcs.status() + return located(result.data ?? [], value?.location) + }, + async diff(value: Parameters[0]) { + const result = await legacy(value.location).vcs.diff({ + mode: value.mode === "working" ? "git" : value.mode, + context: value.context, + }) + return located( + (result.data ?? []).map((file) => ({ + file: file.file, + patch: file.patch ?? "", + additions: file.additions, + deletions: file.deletions, + status: file.status ?? "modified", + })), + value.location, + ) + }, + }, + file: { + ...input.current.file, + async list(value?: Parameters[0]) { + const result = await legacy(value?.location).file.list({ path: value?.path ?? "" }) + return located(result.data ?? [], value?.location) + }, + async find(value: Parameters[0]) { + const result = await legacy(value.location).find.files({ + query: value.query, + type: value.type, + limit: value.limit, + }) + return located( + (result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })), + value.location, + ) + }, + }, + integration: { + ...input.current.integration, + async get(value: Parameters[0]) { + const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map( + (method, index) => + method.type === "api" + ? { type: "key" as const, label: method.label } + : { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts }, + ) + return located( + { + id: value.integrationID, + name: value.integrationID, + methods, + connections: [], + }, + value.location, + ) + }, + connect: { + ...input.current.integration.connect, + key: async (value: Parameters[0]) => { + await legacy(value.location).auth.set({ + providerID: value.integrationID, + auth: { type: "api", key: value.key }, + }) + }, + }, + oauth: { + ...input.current.integration.oauth, + connect: async (value: Parameters[0]) => { + const method = Number(value.methodID) + const result = await legacy(value.location).provider.oauth.authorize( + { providerID: value.integrationID, method, inputs: value.inputs }, + { throwOnError: true }, + ) + if (!result.data) throw new Error("Failed to start OAuth authorization") + return located( + { + attemptID: `${value.integrationID}:${method}`, + url: result.data.url, + instructions: result.data.instructions, + mode: result.data.method, + time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 }, + }, + value.location, + ) + }, + complete: async (value: Parameters[0]) => { + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method, code: value.code }, + { throwOnError: true }, + ) + }, + status: async (value: Parameters[0]) => { + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method }, + { throwOnError: true }, + ) + return located( + { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } }, + value.location, + ) + }, + }, + }, + pty: { + ...input.current.pty, + async shells(value?: Parameters[0]) { + return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location) + }, + async list(value?: Parameters[0]) { + return located((await legacy(value?.location).pty.list()).data ?? [], value?.location) + }, + async create(value?: Parameters[0]) { + const result = await legacy(value?.location).pty.create({ + command: value?.command, + args: value?.args ? [...value.args] : undefined, + cwd: value?.cwd, + title: value?.title, + env: value?.env, + }) + if (!result.data) throw new Error("Failed to create terminal") + return located(result.data, value?.location) + }, + async get(value: Parameters[0]) { + const result = await legacy(value.location).pty.get({ ptyID: value.ptyID }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async update(value: Parameters[0]) { + const result = await legacy(value.location).pty.update({ + ptyID: value.ptyID, + title: value.title, + size: value.size, + }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async remove(value: Parameters[0]) { + await legacy(value.location).pty.remove({ ptyID: value.ptyID }) + }, + async connectToken(value: Parameters[0]) { + const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID }) + if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`) + return located(result.data, value.location) + }, + }, + permission: { + ...input.current.permission, + async reply(value: Parameters[0]) { + await legacy().permission.respond({ + sessionID: value.sessionID, + permissionID: value.requestID, + response: value.reply, + }) + }, + }, + question: { + ...input.current.question, + async reply(value: Parameters[0]) { + await legacy().question.reply({ + requestID: value.requestID, + answers: value.answers.map((answer) => [...answer]), + }) + }, + async reject(value: Parameters[0]) { + await legacy().question.reject({ requestID: value.requestID }) + }, + }, + } +} diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts index b1c8f2c7e2e..69a8c7b3be2 100644 --- a/packages/app/src/utils/server-health.test.ts +++ b/packages/app/src/utils/server-health.test.ts @@ -14,15 +14,45 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { describe("checkServerHealth", () => { test("returns healthy response with version", async () => { - const fetch = (async () => - new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + let request: URL | undefined + const fetch = (async (input: RequestInfo | URL) => { + request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { status: 200, headers: { "content-type": "application/json" }, - })) as unknown as typeof globalThis.fetch + }) + }) as unknown as typeof globalThis.fetch const result = await checkServerHealth(server, fetch) expect(result).toEqual({ healthy: true, version: "1.2.3" }) + expect(request?.pathname).toBe("/api/health") + }) + + test("falls back to the V1 health endpoint", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + if (url.pathname === "/api/health") return new Response(undefined, { status: 404 }) + return Response.json({ healthy: true, version: "1.18.4" }) + }) as unknown as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) + expect(paths).toEqual(["/api/health", "/global/health"]) + }) + + test("falls back when the current health response is malformed", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + if (url.pathname === "/api/health") return Response.json({}) + return Response.json({ healthy: true, version: "1.18.4" }) + }) as unknown as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) + expect(paths).toEqual(["/api/health", "/global/health"]) }) test("allows slow servers thirty seconds by default", async () => { @@ -142,7 +172,7 @@ describe("checkServerHealth", () => { retryDelayMs: 1, }) - expect(count).toBe(3) + expect(count).toBe(6) expect(result).toEqual({ healthy: false }) }) }) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 1b684d9af77..1d7d9e4b2ea 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -1,6 +1,7 @@ import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server" -import { createSdkForServer } from "./server" +import { authTokenFromCredentials, createSdkForServer } from "./server" +import { ClientError, OpenCode } from "@opencode-ai/client" import { Accessor, createEffect, onCleanup } from "solid-js" import { createStore, reconcile } from "solid-js/store" @@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) { function retryable(error: unknown, signal?: AbortSignal) { if (signal?.aborted) return false + if (error instanceof ClientError) return error.reason === "Transport" if (!(error instanceof Error)) return false if (error.name === "AbortError" || error.name === "TimeoutError") return false if (error instanceof TypeError) return true @@ -82,15 +84,31 @@ export async function checkServerHealth( .then(() => attempt(count + 1)) .catch(() => ({ healthy: false })) } - const attempt = (count: number): Promise => - createSdkForServer({ - server, + const attempt = async (count: number): Promise => { + const current = await OpenCode.make({ + baseUrl: server.url, fetch, - signal, + headers: server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } + : undefined, }) + .health.get({ signal }) + .then((x) => + typeof x.healthy === "boolean" + ? { data: { healthy: x.healthy, version: x.version } } + : { error: new Error("Invalid health response") }, + ) + .catch((error) => ({ error })) + if ("data" in current && current.data) return current.data + if (signal?.aborted) return { healthy: false } + + return createSdkForServer({ server, fetch, signal }) .global.health() .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) .catch((error) => next(count, error)) + } return attempt(0).finally(() => timeout?.clear?.()) } diff --git a/packages/app/src/utils/server-protocol.test.ts b/packages/app/src/utils/server-protocol.test.ts new file mode 100644 index 00000000000..2130a968c4b --- /dev/null +++ b/packages/app/src/utils/server-protocol.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { detectServerProtocol } from "./server-protocol" + +const server = { url: "http://localhost:4096" } +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }) +const mockFetch = (run: (input: string | URL | Request) => Promise) => + Object.assign(run, { preconnect: globalThis.fetch.preconnect }) + +describe("detectServerProtocol", () => { + test("prefers the legacy health endpoint when both API generations exist", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" })) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) + + test("recognizes V2 health by its process identifier", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v2") + }) + + test("recognizes the transitional V1 API health response", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) +}) diff --git a/packages/app/src/utils/server-protocol.ts b/packages/app/src/utils/server-protocol.ts new file mode 100644 index 00000000000..27b8dc208ea --- /dev/null +++ b/packages/app/src/utils/server-protocol.ts @@ -0,0 +1,35 @@ +import type { ServerConnection } from "@/context/server" +import { authTokenFromCredentials } from "./server" + +export type ServerProtocol = "v1" | "v2" + +function headers(server: ServerConnection.HttpBase) { + if (!server.password) return + return { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } +} + +async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) { + const response = await fetch(new URL(path, server.url), { + headers: headers(server), + signal: AbortSignal.timeout(5_000), + }) + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return + const value: unknown = await response.json() + if (!value || typeof value !== "object") return + return value +} + +export async function detectServerProtocol( + server: ServerConnection.HttpBase, + fetch: typeof globalThis.fetch, +): Promise { + const legacy = await probe(server, fetch, "/global/health").catch(() => undefined) + if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1" + + const current = await probe(server, fetch, "/api/health").catch(() => undefined) + if (current && "pid" in current && typeof current.pid === "number") return "v2" + if (current && "healthy" in current && current.healthy === true) return "v1" + return "v2" +} diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 603784e4d42..1c8292ca9d9 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,4 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import type { ServerConnection } from "@/context/server" import { decode64 } from "@/utils/base64" @@ -39,3 +40,23 @@ export function createSdkForServer({ baseUrl: server.url, }) } + +export function createApiForServer(input: { + server: ServerConnection.HttpBase + fetch?: typeof globalThis.fetch +}): OpenCodeClient { + return OpenCode.make({ + baseUrl: input.server.url, + fetch: input.fetch, + headers: input.server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ + username: input.server.username, + password: input.server.password, + })}`, + } + : undefined, + }) +} + +export type ServerApi = OpenCodeClient diff --git a/packages/app/vendor/opencode-ai-client-1.17.13.tgz b/packages/app/vendor/opencode-ai-client-1.17.13.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5939f2cb39c27da205f1f37e9971009a669b83c2 GIT binary patch literal 75585 zcmV*8KykkxiwFSar(tRU1MIz9ciYI7DC}gdS+mCTJoAj^$;y!un$}%uIkrEw>`r@P zw;f4#X2z$N4S_`wZ4h7spk&6@|CsmrC-ZyeHSb^Tx&U`$oiU--X&R*L*8&;J)M8g$&@FhHGQ99^N% z4Lx-4SHHg3yZ)cQZ~gDPzs~{4CfU*(_wx_P?V|YM;^aW-{NLVE=KsO=!PY(J^8&`A zr8_?Vcek8D>JQN4?ZZFp;s0&zA9VH(xBhUrzx(aC_xBH-moL6MK0W#2#k=R7&u*H= z9n*AueEgRe$Nw4jPQE>Vmb|-qdw=g+=L}bR`5#4&a{cdc zduO-N|LRC){qGMu`@4VG-EH*0wNHHh!>nJF5;(d3hkxug`d=L>*Z)qRA3uBbyff&| zy$_6!gT1|C{cme`|6pr-``}<}59ogg`hTPU)sPzf?@lLO|C_%YD4YLS+$;0{aKF+2 z>Pd};cefLt|BGAwOXvUY9%e#i{og-m>_4@n|Kt3B(GZ1R)I|?m|DhN7C`|9)e-ryx zZi<{s6r$Ko{U~%Wnl4f5^rG0Ir4k1(7ycFMI$b|TUK)&@UK|aa*SNhC+}{699CwuV zqu5Vz3s=Z_-s|CF&c`3pba*`UKX&fl|3Cl7c^;K4<$tDbrgS+TXI7Og;#zYg#(=V z0K}Q!#ZZ!QpU?r}p`Z6N^x%)lDDJr)a{SN>MqQLR=nBPS2Xjr>b>pt{!}<9e=Q55) zLyDv`6ypLIMP3woF~X=me)FQ^K%*BUKj`B8!O#y-?8JCkP866DQv0Eck@(0-rGYTW z;!z0OddD9OqZpIQWiUPnBF_yR4AjrK7|<2EOX;96V$;Zrg0^$*r+r)}C;tQV%LubX zfX9HG6eTHc0)vP}1U_bC1-2p#8i6gnR-@c&^bGtfUeZZF07^7?LgNk)aM zcqr(I`Y1NGp#_0p}8E61Z8sV`9n5kgu;<{ZY z3h}Ik$=%PU6XWDg#tlw%9b$eCyA))M(j<*Xlw#1!!0&bgbPb&i16&hk%_GL(xHYU3 z_$cAjpu8?r`Gm#^pt0**j7cHF{tpQA!jzzx=Tkn8qLi@-MlTu0D8`6~ar7~!!^k;_ zaI08IDHDMLjPW(@I3Kw*V7W<#2U@A^oDpnN+GKcuAAaIo#L+dLfDbS?VIZ8u?O|kn zM}9oGh(<8q$7G5C(y+M16Xqhq(}_++ECk$PKf!RZ0Rlcp1E5KUSp!G;A!f$p=>GkW zA3tJ2$=dqj9A6|+FiO!IH|@6_&ROkD2)7-|SOQPRp zjdcd@C$#a!xo{KoHV%%QRv(MR2=L$i(FKT}hj=$NaD%ZI<@*$&ZnndK78r;L2Ld(}WFW)Q zMc^m>lPK)@m-$#+&%=8OQWFYzd4--IAo%c}_mYs-ej*>~mORPZ>12Q6A9QyIjdtk4 z{E%*cANqI)`x&F34V~d4v6k$1a37@T4Fk5rOVr&T7^^DviOW^$5y}bu0p9Q5Gz-au zN%H^bd;r5Do`skIbL?aC^Z&!W{YL)RlbZeColesH&o_Z{l>-x>|L<($9bmKnt0OgA zyt|#G`M;3WzkL3~d{Nf_ot?dA{jVcg^gmFn=hnv*`rqN!K|}vqQltOf=~SZs%|#AO zq5tjeZ8!Q~9jVd(?shWje{)^^%l$tO@DJ7gb9mUS|8=B?zx$nYl6&BFQR@0ZLcBa( zhx!U~?;nS=hx5CK_o)Hf0pqOmoV$iSX69XJ^gA~}&fC+MPShiAU1DV@18$AP>uLm! zL`fR^;pL;O_VEbU2~(f?1L4BIjQlwF5o3+DooieQ{HA*Ta#|N|;(M(}Qd8eSp4!$3j}J~p%&qaSGSr0)4&5ZVj^eJlLH<>4 z@aEBd{6A>&^$;3iUUuN>=qFwT&UwV6uo{h@u{yjhk<{ET3y4s4t@Unvp<1nEUuBAz37yK`cHMvIOVLsM(VH=CcOdnL1=^F;}p3`1Wvxm zDCUl$yfwyY;4mAaYw!+(^Bn`rl8@Q?0Z<(n;x<>^x z0AIs+6o$B6OaD1BRPy{*AkP_V4qRJ_S19oey&Qp`W+D^kBR?M_V?fS>2W%`Kb@a04 zFnUi})^QxW<3CblKglU}0xqZEY6^~!s0Z$u$_?;IokH_X=kXKgD)PJ5$&jScaFU5|hRfgDBw#RLft?Zud?s7z&~p}6 z2B5+4BJ4%d;NOP+FC&5RLhxIMKe`}vE{<7srk=;JH0lOE0 zX?5Z?WfIr9{www$>IppOKDH)~|8j7!yWQx2wWP-WbElKV{xerOP&)tB_%AznH+b0C zf9go)_%Hk0yW87`2aN;h+9zrL(^)_FFfe8Fe@l!1a<|p|LubRf202`J~bNN-A>Z{U&!iTVg4Uz@t^j#8~^WG z5;F^bacE$Zz)$(fUeczqOMY|*D4}=e{RAl`j1dNgEO-LHPgAdTvq@!1 z(f=7`=iA5R_TR&;{oQ8%*OD6j|4t{1{y$eaFopdW>jI7bUq>?Ae-FRi`oq@VPGkRF z`y|bOezkVaeN18hJ>1@I?7wv+oBem5E}#?Je|K<&M*piPHTvJ3PG$DrdB}k&?7s)w zP5h@?QlsJB?PRw9&i4Yy6!zbPz2^RBEvd2p-tA=E|8+iOv9agT$HeyEo$amd#{ajT z)ad_rIvMAGz7Lp-9GKkxyR*OB`2W_DEcV}>?Y)DYKQ#8=wNJ+RpUwLDSb`_E|L)-Z zLgW8iOS0&HJhIj;=No$k5 zm-Xb46kX!@k{Xh6d$i&AEf=y{Kgs1zXaEtpB#SDdmVJJuwMkwdRLgzu2S_dk0Wj23 zFTxaE(f}E9VL$(%)_H}J1cM=$AJ9y#tT$B3c|)yC)J1_(3bNF4Z-UXKuMBfY^0bmD zCShn)poUq#R>MvEN?Dwvm7;G&Dg|k#p)5R>8Eb_}u3GvpBb4Yw_=V-FrB6{0#qj>5 zTs-F4YI*kPqFkE2{HB%tA4B?iejE^4Ozu`T6nIMLUW&c-e{BM2#x4jMeUnBo(NX`E5PN$On z-(2Lt6#Kse@c(P}e~VAe{_k!lUndBDe;%}iN`y|T06Ub}vp6@@3P173b}^J#~_n-2Q9rFppyYo0#E-GleUaks1& z8rl(Wce=cI2lls{n;ixr-6QxtXM@yy+My99zj^!^p3-x>vzQtV(TWzOBLX5&6S+rW%ZHvJ3s*goJmJyNm?6x{Me3NiL&Kz{G~0S|+ViAtljLPK91(vmh7 z^*0^FD2!2WL;1v}_!nBHO}js0l&534$!px?tN<~SGf2J^Q)waqZbZhs?!#iY0VLQ| z=87N;o;=Q^L)WA6qLmk4@USJgqyl2BaW;GwL$W4@Yq~N`eokFqS zCJeMG^SamoUln8lt|HYUZFsT?DtX+zr^k+I0Hzv(aDVK4!`JlyUAo>F));LSl4h^- z^=lUYS1h2eLvqhd81Q~^FT4%9PChoRlL|xg8u`LLaSuJhwT3P060D;eSsZ?@ymZLy-Jp{^=QzC)!>Y*BjX@|N%$5oy z0I1Lk#_JZKTVg*S0$qP&m-^~R@|kbqSin4JD36`@czw!Mf}c7Aces)L{+<@w#B((4 zZfp=yCkQ=xI6CooFD1jKtilQ#N9%`v(kCmqI$SI~urv`y%!-;u!#;Hqtj2F__$e9? zSm2KimHz0npOTp0ga(@?juSM8$#~yoSn9_OB6G8O_F|P1IFU_ zxeL6QjX6g_r;G5ais&b$xrYy(KO;1RtpQ#`!mi(ic{>b73CY5W=qIqxUmz!mu(FD^ zp9drl&&D8t8ex<=7bGi2M86+*3?d>MO5*HMwY?>yP zx&aY1D2V_n6GJQE2vZW1qp;AH)jHa77_sY4J!_D-Obv>;F2 zQBfmH6t*@wMo+L}ge9Tr@ti(6E#S`vaZQD5NFt^)kXV%sUWh|R5XUP*H@00{pMQdYF^Iv?TY~;V<;yLaiexDu3p$Hf(m%=!`tuumNua zp@Th)$?WYN)!GAUvPaXZD9zsJNFWR62B>9tKO6+n^-HSnvz>=B&(wFNAd$B{ zpn(qlt1Xe4<#Y1LIFJ^trHHlLse{xCg@+PBy4n~ZUA^rp9;sOBy zK~BFteYw#BcdLig=7n44pkadNK(onmq|cfh15_3sImZ`xo}G~&9k|(nx1<|8WD#a# zE9M|N=8LF17R`IKnL^0J2IK-TX;i5lYJ zz=W;Xu(-41Cj_}cs&MJaca?@!2Qc&&Yb(K-J|<9EHi!v7#hbxlO6(EYk3T+kwzu-% zzyG}|phPH*0z=;J5>q^H8=98uh#RN8x)_r0 z@B@oJk}q1$=trKzOW>7A_zJ)Hm!Z_?kUX)bBHnBylBOj*xQCLI*eN(GlN_KGuPe+_ zp*%I#fg7S^OcT#E3^hh!G8%#|3YD}Y2yYa})ECyO+Vgg^qUu)W8$uHHxg*Vf^2^95 zdE0DSiZwc9dbG}7{OSDp>8sYJDxK+AH8xk9*fb-FLLOiVoD>31EpD`w8D{#m7-X^M zLQwzw;^oVh(DgUf!OVM8Evo9C_%~nF+Ba~zh&N++$H~qZKW-WqAe~EvEsWHv-?baB z=}M=qG!mOmtWC1UAA)ph$&}D^%K^e0B&^7`-n>3L7qo*+=llYvaa{3Kt>3UOp}X?* z0cg%G+z_|{v7O;VvW4VoF(gnNj_BrsK6&T&#v5Oi~9pLmfjl0FhB}MUNd5l%6wkuSoH_6b5=|DH}>!zIHIpM zf<7Zy5hyD8!={hnP?h;owArzLo9G|y%ra5)vx+N|O8md)atmk*|L@&{y~h8mmelxv z-|1A*|9d7mFuDIXygu0Yf7g*3|L?n<3jM$5bO*TX{_nxofp-6^dH-OI6A}?@bWcS~GdZa?QdBpc2f8?M#U-O;U zlSlLMOx8vw`zGUYN$53*`-V|F`t@fA^rd|65B^_h>?xC&C3y!2b141@T{NoBsy=e`l|W|6fmP^#41Z zD#U-CSq@CD|8E~2?lt;<9jVd(?{>1rf1TUvUqS!h(&E2vZ8zsXwWLD(Z$0OKCjXzU z-G=`4q(=X{)2WjEcSbodh5dK`pn3nbmelBfcRLl>e`|aH=Wy##)&CB5n)SbyRKfl$ z#y0EtG~^o@wb(L`)whZewzi$Z)btb+j1cLHjh%@&O_n1g_!hh zF+_S>3@P81BF49cc<}8k-g}!xbZ@h$?fEOV9kvV75J12?8(kz`>|dbQqjWfe020pY zAqpW91^F&ClOq%wP}}3Rrh0-Qe}7?W`j@=_*7=Y<_Aotzt&?T`rH@XN^@nIUpZGJ1Cd$pT_Op}Nf4!#XDEov@J`Vy}`` z^pHwKQc%#0&I zVF(A9my;v3$-HfRB_pY#&uj>>O$kr^?)}+#a1jL^^7BOss(Tdww4vf<9vn40SLw8< z#8|xJZ~TPDtWLp1D%5I0kkW_bQP>A~MM3VMWei1b=8g$DM5c3wU?ZoHPHmzlixDIM zrN-{Qv1315V9mOgrQ26-fK{pi>YlQ8)E+sfxb(3Wsk_c4N>9lbbC7(z3`DdV^m~qp z@{H!F#UbY21)1TvY^(LC!W_1|*`cMvyMBPx#1p(S`0gd5$0YD_NIrZB=t@V4H&)4C{j|T) z`hT&lT@otU*s2|rj9*zl3D(}x##Y;Tux;r@t%0?^!1d5^5Wv{}UkJMZ{QsNE2cA9m zp<}Lq=I!ko&`F;UGdx#fYCFWkHn&bDJUnHBRe~HL;L!8PC;cR2;J0$24E1@qL4ZX-UOx_gx8_2EqOXXC88aF)yH@&VevOgUT34N8;S zPI@&Osku?@%@sEYOr$N>w#X_wA1y0evIFEM=w$?9^KGgW57aKdbCC~~M*2`KD_HkA zayAS~Im_Dkl84j$)AMr@C>~n*D8zXG*mflK_{iDW+G;!M_8HI6y{+xGv$wVNQ`;dM z9TuenG8y zlL4UPM=Mi^FRiqQKh>&aN2V||`mo%5`GK~%Sbh*dfZ!-Jo{9;GnYe|(R>&;8a|5Fg z6FG27K0q>%ljT|ap-z)xH#Elz$LN9*CkKS)*eZCVIEh3w(F{}Zc}|8c zm<-!3Bl zL|+G@*=?{>XP_qet3!60a$VO;{VPq|x|&%GJ$wH0`T6tO5xX}}#FDqtClcX3 ztN_#}z$@bIA&jbEw>+RXBZSRzmNRD!@;2Bxx%N}9e@s`UWuuI3|HMfnE~eR1T8SjL zJP|0?U(w}#*|1KujG_r4XeF6WvIa@i10$5nvw)K3GHVVwM04UEGc%b$I< zT!3Vu16zSLa~|5pB4mUSd7NR@&9J-`&W!`YqAKLA25*CF{%{EwO**b=cdtczSMcY_ zr4asMD+upgCRs`Ht8hP@$ZNQ@FvgloPE&JDjAmGkGAO{aTx3I*VXF|MWHiV{E&aLZ z?en)8gYMO<{CV__@bd968`vV4ID?s$WWTNa8DC-!kXV%GR59^PVBje+?<{Igt>6^d zuYHpQchi?|mXKi;o%1U5fy2S_$S=sYP;?a*;ba_QDyF`-gw)|fyVa$byYA+OVVL1r z-d$X2Z^g{gM@uO|U|5q#5bNr>3c`Xs5#%kQ%FYH>oe=9WDZt-s`G^6dnzLPCe`|M9 z7A-7djkgVx6svtxBDV^HXGAg`_(L*E1F|7ivcI>@da-GK5Uf;~BarM;>Dx^Gv{Dn2 zeZoMRWzsa5hp_G-XQUsb3N(Z?106*OAF^;47NSC`N?1r!5|`k$kid{;UAqa|MsuUjo3}O6 zr>O2HG&cI6@Wjb>Z?M>u!B0@n9E7bb<<23;zvDTHsy?N*ejK>bwxSBRxD9q>M>cg&PPY3*-k1j^xlp zGk`NlR_@S5f6mS_lbnOk^$6UsF0nz?q^kfV8Z2@iTu^w@y~dMz5?WT7)N|7A zn6v*Htm*a}g^(7$syB-_PxUU|jOhhu6b) zIK8o|#Aj1MVDXtXgejh@u`pLi!=8UBsHE_7&e!|Os4~qxsM>ARY5(rDP7hv)Y3`N;gZ?X^$uH5d08=Y$Spf zPBRv9A7BAnBySYQbBHHf$%%;2hIP2Mdrlra(MQu--ljlmHHLpEjBO2lSA?O#J7cQ2 zWc6&59XO(YrK&t!ojTv!k4Pa#qrQ9xzcjolq^+Pen4E$Wg zT3j4uctkTf_cCGW=Di0le}%t*7FGRM3c%d2%`O1lqIm$t1K;@S+|sDL`4Ah$7Z20DLPk%W zTO$e?Q5i|HouvK*p6wj~0`1ZeWwPKuGlzwl+~Y!}NTJ(B*XlH}{EiQhmUWks6JZg- zpj}V0tk9nYahJ*QQNEwaa;%R|x>xy@34Y8iYBWDu<870lGse|l&WI@!1h-j66GkBe zuM~Qhalb3b=z0$kB@dyaj7D$pH-0jK|6JKiw2&_R(2b|sDA%)YVBAATQy;bQ^Wt3FCy!U( zHtI`XF7;=R%2f%u(9x9a+vcU%7xIhPyvIYq@~+WZ;TK(z-HeQ=WOM;f^ITXSHz#o|i~Mg$ql@*v5sieYwr?d5 z%GBYckdEo&HIq0UtPY<*swt#VYEoa<1B10$gXWFIB8!mx0u!`V{(@m5l!l+yw-K+U zcCYQvj|r#fP?Vpen+<&7l~5G_TC~+>RU-z0P%HAfO2^9*A!t#agL880EWKYn=nyt?d zq9Gl~gVgsvp|q7XqJNt2#M#YXWnSD)i@ptcD?2UA`;U$OH<_7vLNv}#|0~u0a+R(` zp&OuRBs?igf6wKhZ^^l&o&zD^kSUJd*G99;7;N!3b}HfeAsf&_bUe#kS_wkokq&34 z)0}3+LpqwN4dRyg`*m2q*FfdIb)j{O8n-Y!Qev4sA^89sKgcEZp5X*)+%^{CwvqAw zoSZhOk9_VR$9^xrcLt@=&-FwJSvZk?HU}12CWH(85ThQ7F=V&&(oxp^TJGlczy&AK zP}?Hcvfr1&$4_`y+QWlhz=k79;fi^%xKmx~%|1 zozgMD9RS`g0k0KZfWF(Z4CKAn_k%8N{3jUEYf`*}DFSi7I25HL{P>$Mc#t>mfAfVj zc=OZ83IjHv`V4m)Uh-~%6WnARdQQdmni?f9V=jJg;Tt>4qSA|lgxtO#-U z+VxW>YZ|t9PdhYD#D;*2o9r(Hr~kLu!K#Xv1?yIerUf&xA;vh=a&9&? zwj`YcJGM5mNIqkrG5YnFSQRV-rY%Y_f42>`6UL zCq)ugV>l)$AK}ioH{Wml^t6Mg#9(8ylSVJ2YZRZj7}QOIOx_PnlY32C`ZRRCPwpjx z=-MgRDh7UnvWKL*4?7=#2a?Xu$qapLZEYRw?K$x8;lV!ncWa0K-lBi+I$OK@2V2|Q z2M1ew&eqP({^8C&XKQXy7Nim0$i{dm7cmz2Q5az@u5T)bi}iX1?NCsh?B7*M|M0K> zc<62@BgmWzKw{|tL4-=059 z-d(-DzxSHI(3-_q9q-Gkj`{jVcoeI0L<9A*MY z-iGj0Wfc2=LtU~3!P_&zSI4@Qhutg9{f5~uAgA>qbuU|v&nzxkq4)H|PqYcG&(@>+ z%CKI9xSo$ULBu9h*T4+DT8DN*oIPtEnb4^|urulo^JA2LAC1D=sA=j}AKWV%f~M}l znYz^nhj-Au!1rq7GpRq*aLApDx^ZRd);hT7&;T)K)jT|+JgnmY0;`SBP?`qyshgIb%|m30)-mzPu5*NX#|FO3 z0tnX5dgh)DLrllDk4@>)NZ1R%+eNjlA##U?br!BZ)vlvxo$3So3v;8ddu+yjt%4W4 zv*$1};*V?^8FN$?g>Kd*9@K4b;b%$}&wsj|bb1@m;K{t)2M;Y&c7v5 z_>OAJR4e}KTtrcT+z|g2js_Pf#(yNl9TNXRd`kbAReFN6sBhW(PiTC^%KdbdH~Z-R zO{T(=4w)emuP{N{$DPvZee#p}22&TeD-_e$p=hHta|q9s&|%qHK1-t^_ngbxcRddc zQ+U6KL8sMz6?IW?ih5ZU=t=6DS+3wEAu<2TjeR#vpXRwr%iQDzmIwSj$$EJIxe~^X z;T@8bC?IW^ac|+UMQ$)8;a+$R$l@Vj#Jm`}7zManIoor+zI5Z6z5pN= zfpi`Fa4}cRzR|}2lKH-iWEO5bid4Oh;!g<{l<37XJ7vH31N6-A^(1iVs|6D0fd8kQ z<#xM1adMZ3j9CyFviTja7OsZ6jHb9G`+E>|{hp717^Vbai(@bKuky)2qqVoTYVf_Pb%R{mi}s6-H* zVa5YzU#py)1}A)+VinIc<{N3~kTYf=(EpPFCM%O{sydO6B){6DQ0$k1bKPkTAuy4RKUtxGZ*?iVYeoSZNbn>xp!kY>(O%p3#I@`crw5-#DN*hrZ|XBjHA2%xe%Dbqp>&47Mk zX$G_`lO92)i?7Z^n45Mrj$!1C9HNin{8&=#QJ#PPhwok}iQhH=&g zGwuD>`2u%KwSG?Ap?l#6e(Iy#aFyb6&RO`~PNDGhF>_qT z#409Py5O6>qv`qyxQ>FDk%-Su6jqj4FLEsWwU2V!i(u4dygbD782v&fQ;7eixx{Cr zU^UMgXAaD-NZ1c6O>(t|#NDTvfqQ|JWhh&}if5M4KUO4^zFA)wP_ZAot}Hb6JiIbD z1`jah9F&xe?}R?^0qDWHH<E*m94Zw$@!T9G6Kd#^Jd! zn*KJ~m{$m#Y7>?*m}-Ds5>T6I+)QJe>gf$BhqEP5*og?t!anH-2w8Pz0VtX$wyMvV zqNvy9Dr7U!t+T<;!Q9X@Ua-tXB4IJW3;ZLZc<_oD zy(T@TfVwm9#Y7*bGHIH7J{iYCgA!(=QMP=LW!RyancmR)^Uo=YLpR{o91F;Nr@~5S z2OxpEe5QfQ-<5~Ly}@)0=8j>?hR)`UWcCdK>yfdxg4%_Khsg zw&H)|rJ8S9%p_l-yAM52Csz5aPa2=oKqqoYMZ9&gAnLl|5lqsBAQ;RrR#}<^Zqiq+ zGjK5BhgZ=jq<+ccgUX_iSPTpC0B%n&(WUE+HRmT~NW}X|$(zRg>FE^yPdTR?bEleW zesK`a@679?fkkeajKAFC2HJ*vf=DUY(h9Iypqlq=o`%AVDYO2GH<)+SaVXcDO+zqt zLnle^sUTgm5T{mMls5F5*nnT6@X~@CTRC}r*7lDtNJPq_ro|$YKy5ff$qtI?aE-kh z3r&vg6vCgD9qOFWVBfE?X4AvWZ5)rO%;dwNhUxF9wY54IutR(-IMVz|tv)=d#B4M= ze)GcE56_qVxbp-UHnt+oK-(%#k<}D z;v29u$-%c=Ta`GYixe+X^T%&O8*XLF(qL#m)>qbW+0T;n1!s6qD=%eGTK!|AvIxXKS$Z45a*y*V4s&V@#84Ulohw)KJ&nqd=D=3=d)+YK*~ycrfj04H#R zw?f~Iu>y`_ZC%T{o4yHy8yPvpuVBm~oI;PWOUcK!4DPj6OU`;$y}m4-R+IHCZ}H|S zdMdJF0jlMNH#eOM>+WR0Dz>var&_5$O^U!$7%yP$!yC=v12Qs(v?O$gNk2-fNkSOe z_kQRn{RIuJM;d2i<6dx5)5YT@^EgTryr;xVLh^QqQa32my7W6nkWBZC^3nS0T_Z9g z^}Z1+Z}7T;WVm-EFs^}L7)};I6V|fAEOsjj?91mr46@y4W9YkyVi*DYK&e9jxo0x} zkr9Q3jZC+xP}`3rP7KQUcm*EOB8z44(0~e?l@ttXe2I+Qac@6F+o;5ivx_Lm&TVmhmq2nXJ>)qC{u`i zg{JUT+5~C{;9+du?I;%GV)L+dn&$1n`kJ*io#ixdj0Ty)%@vq(Mf3f3bvqch+~-wh zHQn3n@|oklbezv7f!_^`7ZAaw!kwL%JA>+`Yp?;(=_H8<(~?J+hV6OuF9sI2c4%Q4 zlI@3|6oV>TZ$h#q$v9I5{bH0DEYO@O=F6{hoT9-3Rg@usz6J} z=f3v|rR*@BcyZ>uMn1`{vV;5&erUg{&Yjw~?Sg-8yxObXy_kb<`ze2+8t!=q=EEmA z@!+RVI*Zs3R{A8C+fzHAWS#%XPt9G!xiwdohf(6|C#n~*8)|1IGmJ?HTn7IY9q1}G z-+762u45Tl&m9Hncj`C2Epl=uOKF|kYPu5-FT_c(^&?FeOf~lfFA979rJM&b0@4_1 zKGJ2#mG2r5$MaA*$d6Y%TU=t`tSAIl}=JF4^o(tkg!no|{P8M3-0n zNSO6e9wW1GswbbBx}UjO6Rz=w28LnlIg1G|IK)u@_9HFA2b-SM#y_bW`}AJylI}eU z#ONngPP-u&k?p>n@Wfy_Fo!W5sJywuwM_rF}x*C{+G<3ml*ZxI|`vj>MM}w*&^#z=b zKtQdiem*AWG7ue(r$De(jTKC?q1-O0&$%1;4aBq zdh4Thp`4GP!!Dkz&U%SeBe|>wes+M=ijla{62@YsPI*MY#n40y=(i#>Lic~8KF5D$wkCSig=%D2Bq7QLU=di+aToAGv5qRD*!rW}%0+Q4~CY zQ*Y`6!!BAq=#g6w^n{lXsGIv_&C#d?o6gfPa+iN>sO z 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

    $=Nes$vsW~pOCtUnEt6JQ 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 literal 0 HcmV?d00001 diff --git a/packages/core/package.json b/packages/core/package.json index 7756c08d7e0..86f97f7d509 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -77,7 +77,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/mistral": "3.0.51", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", diff --git a/packages/core/test/provider-mistral.test.ts b/packages/core/test/provider-mistral.test.ts new file mode 100644 index 00000000000..6e3176695f6 --- /dev/null +++ b/packages/core/test/provider-mistral.test.ts @@ -0,0 +1,282 @@ +import { createMistral } from "@ai-sdk/mistral" +import { expect, test } from "bun:test" + +test("Mistral sends promptCacheKey as prompt_cache_key", async () => { + let body: Record | undefined + const mockFetch = Object.assign( + async (_input: Parameters[0], init?: RequestInit) => { + body = JSON.parse(String(init?.body)) + return Response.json({ + id: "response-1", + created: 0, + model: "mistral-large-latest", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-large-latest") + + await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + providerOptions: { mistral: { promptCacheKey: "session-123" } }, + }) + + expect(body?.prompt_cache_key).toBe("session-123") +}) + +test("Mistral round-trips native reasoning in assistant history", async () => { + let body: { messages?: unknown[] } | undefined + const mockFetch = Object.assign( + async (_input: Parameters[0], init?: RequestInit) => { + body = JSON.parse(String(init?.body)) + return Response.json({ + id: "response-1", + created: 0, + model: "mistral-small-latest", + object: "chat.completion", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: [ + { + type: "thinking", + thinking: [ + { type: "text", text: "The user is greeting me." }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + }, + { type: "text", text: "Hi" }, + ], + }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") + + const first = await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + }) + const reasoning = first.content.find((part) => part.type === "reasoning") + const text = first.content.find((part) => part.type === "text") + if (!reasoning || !text) throw new Error("expected reasoning and text") + + await model.doGenerate({ + prompt: [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + role: "assistant", + content: [{ ...reasoning, providerOptions: reasoning.providerMetadata }, text], + }, + { role: "user", content: [{ type: "text", text: "Hello again" }] }, + ], + }) + + expect(body?.messages?.[1]).toEqual({ + role: "assistant", + content: [ + { + type: "thinking", + thinking: [ + { type: "text", text: "The user is greeting me." }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + }, + { type: "text", text: "Hi" }, + ], + }) + + await model.doGenerate({ + prompt: [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "Hi" }, + ], + }, + { role: "user", content: [{ type: "text", text: "Hello again" }] }, + ], + }) + expect(body?.messages?.[1]).toEqual({ role: "assistant", content: "thinkingHi" }) +}) + +test("Mistral preserves native reasoning metadata while streaming", async () => { + const chunks = [ + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [ + { + index: 0, + delta: { + role: "assistant", + content: [ + { + type: "thinking", + thinking: [ + { type: "text", text: "thinking" }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + ], + }, + ], + }, + }, + ], + }, + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [ + { + index: 0, + delta: { + content: [ + { + type: "thinking", + thinking: [{ type: "reference", reference_ids: [1, "source-2"] }], + closed: true, + signature: "sig-123", + }, + ], + }, + }, + ], + }, + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [{ index: 0, delta: { content: [{ type: "text", text: "answer" }] } }], + }, + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ] + const mockFetch = Object.assign( + async () => + new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), { + headers: { "Content-Type": "text/event-stream" }, + }), + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") + const result = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + }) + const events = [] + for await (const event of result.stream) events.push(event) + + expect(events.find((event) => event.type === "reasoning-end")?.providerMetadata).toEqual({ + mistral: { + thinking: { + type: "thinking", + thinking: [ + { type: "text", text: "thinking" }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + }, + }, + }) + expect( + events + .filter((event) => event.type === "reasoning-start" || event.type === "reasoning-delta") + .every((event) => event.providerMetadata === undefined), + ).toBe(true) +}) + +test("Mistral preserves metadata-only thinking chunks", async () => { + const thinking = { + type: "thinking" as const, + thinking: [ + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + } + const mockFetch = Object.assign( + async () => + Response.json({ + id: "response-1", + created: 0, + model: "mistral-small-latest", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: [thinking] }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") + const result = await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + }) + + expect(result.content).toEqual([ + { + type: "reasoning", + text: "", + providerMetadata: { mistral: { thinking } }, + }, + ]) +}) diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 75ff5029287..05eeab00d9e 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -181,9 +181,9 @@ export async function spawnLocalServer( } export async function checkHealth(url: string, password?: string | null): Promise { - let healthUrl: URL + let healthUrls: URL[] try { - healthUrl = new URL("/global/health", url) + healthUrls = [new URL("/api/health", url), new URL("/global/health", url)] } catch { return false } @@ -194,16 +194,17 @@ export async function checkHealth(url: string, password?: string | null): Promis headers.set("authorization", `Basic ${auth}`) } - try { - const res = await fetch(healthUrl, { - method: "GET", - headers, - signal: AbortSignal.timeout(3000), - }) - return res.ok - } catch { - return false + for (const healthUrl of healthUrls) { + try { + const res = await fetch(healthUrl, { + method: "GET", + headers, + signal: AbortSignal.timeout(3000), + }) + if (res.ok) return true + } catch {} } + return false } function createSidecarEnv(): Record { diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 96611da331e..3cf74b043b6 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/patches/@ai-sdk%2Fmistral@3.0.51.patch b/patches/@ai-sdk%2Fmistral@3.0.51.patch new file mode 100644 index 00000000000..141b14a689b --- /dev/null +++ b/patches/@ai-sdk%2Fmistral@3.0.51.patch @@ -0,0 +1,709 @@ +diff --git a/dist/index.d.mts b/dist/index.d.mts +index 1bde0b9f8cbe6771a52c1041095c9dddfe8e5b6c..0ca2ffb2a0c9327aed5ddcf0004500dc8b42569f 100644 +--- a/dist/index.d.mts ++++ b/dist/index.d.mts +@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{ + none: "none"; + high: "high"; + }>>; ++ promptCacheKey: z.ZodOptional; + }, z.core.$strip>; + type MistralLanguageModelOptions = z.infer; + +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 1bde0b9f8cbe6771a52c1041095c9dddfe8e5b6c..0ca2ffb2a0c9327aed5ddcf0004500dc8b42569f 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{ + none: "none"; + high: "high"; + }>>; ++ promptCacheKey: z.ZodOptional; + }, z.core.$strip>; + type MistralLanguageModelOptions = z.infer; + +diff --git a/dist/index.js b/dist/index.js +index d3f904c12a1d582cc7b9e9a2d30273e1a8505b28..267f34e20ea392b7a85ad5259d72d50605a6f971 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -128,11 +128,14 @@ function convertToMistralChatMessages(prompt) { + } + case "assistant": { + let text = ""; ++ const structuredContent = []; ++ let hasNativeReasoning = false; + const toolCalls = []; + for (const part of content) { + switch (part.type) { + case "text": { + text += part.text; ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + case "tool-call": { +@@ -148,6 +151,13 @@ function convertToMistralChatMessages(prompt) { + } + case "reasoning": { + text += part.text; ++ const native = part.providerOptions?.mistral?.thinking; ++ if (native?.type === "thinking") { ++ hasNativeReasoning = true; ++ structuredContent.push(native); ++ break; ++ } ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + default: { +@@ -159,7 +169,7 @@ function convertToMistralChatMessages(prompt) { + } + messages.push({ + role: "assistant", +- content: text, ++ content: hasNativeReasoning ? structuredContent : text, + prefix: isLastMessage ? true : void 0, + tool_calls: toolCalls.length > 0 ? toolCalls : void 0 + }); +@@ -268,7 +278,8 @@ var mistralLanguageModelOptions = import_v4.z.object({ + * - `'high'`: Enable reasoning + * - `'none'`: Disable reasoning + */ +- reasoningEffort: import_v4.z.enum(["high", "none"]).optional() ++ reasoningEffort: import_v4.z.enum(["high", "none"]).optional(), ++ promptCacheKey: import_v4.z.string().optional() + }); + + // src/mistral-error.ts +@@ -407,6 +418,7 @@ var MistralChatLanguageModel = class { + stop: stopSequences, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + // response format: + response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? { + type: "json_schema", +@@ -465,9 +477,11 @@ var MistralChatLanguageModel = class { + for (const part of choice.message.content) { + if (part.type === "thinking") { + const reasoningText = extractReasoningContent(part.thinking); +- if (reasoningText.length > 0) { +- content.push({ type: "reasoning", text: reasoningText }); +- } ++ content.push({ ++ type: "reasoning", ++ text: reasoningText, ++ providerMetadata: { mistral: { thinking: part } } ++ }); + } else if (part.type === "text") { + if (part.text.length > 0) { + content.push({ type: "text", text: part.text }); +@@ -528,6 +542,7 @@ var MistralChatLanguageModel = class { + let isFirstChunk = true; + let activeText = false; + let activeReasoningId = null; ++ let activeThinking = null; + const generateId2 = this.generateId; + return { + stream: response.pipeThrough( +@@ -561,18 +576,19 @@ var MistralChatLanguageModel = class { + for (const part of delta.content) { + if (part.type === "thinking") { + const reasoningDelta = extractReasoningContent(part.thinking); +- if (reasoningDelta.length > 0) { +- if (activeReasoningId == null) { +- if (activeText) { +- controller.enqueue({ type: "text-end", id: "0" }); +- activeText = false; +- } +- activeReasoningId = generateId2(); +- controller.enqueue({ +- type: "reasoning-start", +- id: activeReasoningId +- }); ++ activeThinking = mergeThinking(activeThinking, part); ++ if (activeReasoningId == null) { ++ if (activeText) { ++ controller.enqueue({ type: "text-end", id: "0" }); ++ activeText = false; + } ++ activeReasoningId = generateId2(); ++ controller.enqueue({ ++ type: "reasoning-start", ++ id: activeReasoningId ++ }); ++ } ++ if (reasoningDelta.length > 0) { + controller.enqueue({ + type: "reasoning-delta", + id: activeReasoningId, +@@ -587,9 +603,11 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + activeReasoningId = null; ++ activeThinking = null; + } + controller.enqueue({ type: "text-start", id: "0" }); + activeText = true; +@@ -638,7 +656,8 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + } + if (activeText) { +@@ -660,6 +679,13 @@ var MistralChatLanguageModel = class { + function extractReasoningContent(thinking) { + return thinking.filter((chunk) => chunk.type === "text").map((chunk) => chunk.text).join(""); + } ++function mergeThinking(current, next) { ++ if (current === null) return { ...next, thinking: [...next.thinking] }; ++ current.thinking.push(...next.thinking); ++ if (next.closed !== void 0) current.closed = next.closed; ++ if (next.signature !== void 0) current.signature = next.signature; ++ return current; ++} + function extractTextContent(content) { + if (typeof content === "string") { + return content; +@@ -686,6 +712,30 @@ function extractTextContent(content) { + } + return textContent.length ? textContent.join("") : void 0; + } ++var mistralThinkingContentSchema = import_v43.z.discriminatedUnion("type", [ ++ import_v43.z.object({ ++ type: import_v43.z.literal("text"), ++ text: import_v43.z.string() ++ }), ++ import_v43.z.object({ ++ type: import_v43.z.literal("tool_reference"), ++ tool: import_v43.z.string(), ++ title: import_v43.z.string(), ++ url: import_v43.z.string().nullish(), ++ favicon: import_v43.z.string().nullish(), ++ description: import_v43.z.string().nullish() ++ }), ++ import_v43.z.object({ ++ type: import_v43.z.literal("reference"), ++ reference_ids: import_v43.z.array(import_v43.z.union([import_v43.z.string(), import_v43.z.number().int()])) ++ }) ++]); ++var mistralThinkChunkSchema = import_v43.z.object({ ++ type: import_v43.z.literal("thinking"), ++ thinking: import_v43.z.array(mistralThinkingContentSchema), ++ closed: import_v43.z.boolean().optional(), ++ signature: import_v43.z.string().nullish() ++}); + var mistralContentSchema = import_v43.z.union([ + import_v43.z.string(), + import_v43.z.array( +@@ -708,15 +758,7 @@ var mistralContentSchema = import_v43.z.union([ + type: import_v43.z.literal("reference"), + reference_ids: import_v43.z.array(import_v43.z.union([import_v43.z.string(), import_v43.z.number()])) + }), +- import_v43.z.object({ +- type: import_v43.z.literal("thinking"), +- thinking: import_v43.z.array( +- import_v43.z.object({ +- type: import_v43.z.literal("text"), +- text: import_v43.z.string() +- }) +- ) +- }) ++ mistralThinkChunkSchema + ]) + ) + ]).nullish(); +diff --git a/dist/index.mjs b/dist/index.mjs +index d2eff622c1b84a96bdeb4012cb0206a33012a04d..3bff11ddd6136ada45809568828cbc8f2493a42a 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -116,11 +116,14 @@ function convertToMistralChatMessages(prompt) { + } + case "assistant": { + let text = ""; ++ const structuredContent = []; ++ let hasNativeReasoning = false; + const toolCalls = []; + for (const part of content) { + switch (part.type) { + case "text": { + text += part.text; ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + case "tool-call": { +@@ -136,6 +139,13 @@ function convertToMistralChatMessages(prompt) { + } + case "reasoning": { + text += part.text; ++ const native = part.providerOptions?.mistral?.thinking; ++ if (native?.type === "thinking") { ++ hasNativeReasoning = true; ++ structuredContent.push(native); ++ break; ++ } ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + default: { +@@ -147,7 +157,7 @@ function convertToMistralChatMessages(prompt) { + } + messages.push({ + role: "assistant", +- content: text, ++ content: hasNativeReasoning ? structuredContent : text, + prefix: isLastMessage ? true : void 0, + tool_calls: toolCalls.length > 0 ? toolCalls : void 0 + }); +@@ -256,7 +266,8 @@ var mistralLanguageModelOptions = z.object({ + * - `'high'`: Enable reasoning + * - `'none'`: Disable reasoning + */ +- reasoningEffort: z.enum(["high", "none"]).optional() ++ reasoningEffort: z.enum(["high", "none"]).optional(), ++ promptCacheKey: z.string().optional() + }); + + // src/mistral-error.ts +@@ -397,6 +408,7 @@ var MistralChatLanguageModel = class { + stop: stopSequences, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + // response format: + response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? { + type: "json_schema", +@@ -455,9 +467,11 @@ var MistralChatLanguageModel = class { + for (const part of choice.message.content) { + if (part.type === "thinking") { + const reasoningText = extractReasoningContent(part.thinking); +- if (reasoningText.length > 0) { +- content.push({ type: "reasoning", text: reasoningText }); +- } ++ content.push({ ++ type: "reasoning", ++ text: reasoningText, ++ providerMetadata: { mistral: { thinking: part } } ++ }); + } else if (part.type === "text") { + if (part.text.length > 0) { + content.push({ type: "text", text: part.text }); +@@ -518,6 +532,7 @@ var MistralChatLanguageModel = class { + let isFirstChunk = true; + let activeText = false; + let activeReasoningId = null; ++ let activeThinking = null; + const generateId2 = this.generateId; + return { + stream: response.pipeThrough( +@@ -551,18 +566,19 @@ var MistralChatLanguageModel = class { + for (const part of delta.content) { + if (part.type === "thinking") { + const reasoningDelta = extractReasoningContent(part.thinking); +- if (reasoningDelta.length > 0) { +- if (activeReasoningId == null) { +- if (activeText) { +- controller.enqueue({ type: "text-end", id: "0" }); +- activeText = false; +- } +- activeReasoningId = generateId2(); +- controller.enqueue({ +- type: "reasoning-start", +- id: activeReasoningId +- }); ++ activeThinking = mergeThinking(activeThinking, part); ++ if (activeReasoningId == null) { ++ if (activeText) { ++ controller.enqueue({ type: "text-end", id: "0" }); ++ activeText = false; + } ++ activeReasoningId = generateId2(); ++ controller.enqueue({ ++ type: "reasoning-start", ++ id: activeReasoningId ++ }); ++ } ++ if (reasoningDelta.length > 0) { + controller.enqueue({ + type: "reasoning-delta", + id: activeReasoningId, +@@ -577,9 +593,11 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + activeReasoningId = null; ++ activeThinking = null; + } + controller.enqueue({ type: "text-start", id: "0" }); + activeText = true; +@@ -628,7 +646,8 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + } + if (activeText) { +@@ -650,6 +669,13 @@ var MistralChatLanguageModel = class { + function extractReasoningContent(thinking) { + return thinking.filter((chunk) => chunk.type === "text").map((chunk) => chunk.text).join(""); + } ++function mergeThinking(current, next) { ++ if (current === null) return { ...next, thinking: [...next.thinking] }; ++ current.thinking.push(...next.thinking); ++ if (next.closed !== void 0) current.closed = next.closed; ++ if (next.signature !== void 0) current.signature = next.signature; ++ return current; ++} + function extractTextContent(content) { + if (typeof content === "string") { + return content; +@@ -676,6 +702,30 @@ function extractTextContent(content) { + } + return textContent.length ? textContent.join("") : void 0; + } ++var mistralThinkingContentSchema = z3.discriminatedUnion("type", [ ++ z3.object({ ++ type: z3.literal("text"), ++ text: z3.string() ++ }), ++ z3.object({ ++ type: z3.literal("tool_reference"), ++ tool: z3.string(), ++ title: z3.string(), ++ url: z3.string().nullish(), ++ favicon: z3.string().nullish(), ++ description: z3.string().nullish() ++ }), ++ z3.object({ ++ type: z3.literal("reference"), ++ reference_ids: z3.array(z3.union([z3.string(), z3.number().int()])) ++ }) ++]); ++var mistralThinkChunkSchema = z3.object({ ++ type: z3.literal("thinking"), ++ thinking: z3.array(mistralThinkingContentSchema), ++ closed: z3.boolean().optional(), ++ signature: z3.string().nullish() ++}); + var mistralContentSchema = z3.union([ + z3.string(), + z3.array( +@@ -698,15 +748,7 @@ var mistralContentSchema = z3.union([ + type: z3.literal("reference"), + reference_ids: z3.array(z3.union([z3.string(), z3.number()])) + }), +- z3.object({ +- type: z3.literal("thinking"), +- thinking: z3.array( +- z3.object({ +- type: z3.literal("text"), +- text: z3.string() +- }) +- ) +- }) ++ mistralThinkChunkSchema + ]) + ) + ]).nullish(); +diff --git a/src/convert-to-mistral-chat-messages.ts b/src/convert-to-mistral-chat-messages.ts +index 3c6914f8da615d7517bc43dd56198298d0a50247..8cd6f4c7577f746ef41e8a0aee682234c473667a 100644 +--- a/src/convert-to-mistral-chat-messages.ts ++++ b/src/convert-to-mistral-chat-messages.ts +@@ -3,7 +3,11 @@ import { + type LanguageModelV3DataContent, + type LanguageModelV3Prompt, + } from '@ai-sdk/provider'; +-import type { MistralPrompt } from './mistral-chat-prompt'; ++import type { ++ MistralAssistantMessageContent, ++ MistralPrompt, ++ MistralThinkChunk, ++} from './mistral-chat-prompt'; + import { convertToBase64 } from '@ai-sdk/provider-utils'; + + function formatFileUrl({ +@@ -76,6 +80,8 @@ export function convertToMistralChatMessages( + + case 'assistant': { + let text = ''; ++ const structuredContent: Array = []; ++ let hasNativeReasoning = false; + const toolCalls: Array<{ + id: string; + type: 'function'; +@@ -86,6 +92,7 @@ export function convertToMistralChatMessages( + switch (part.type) { + case 'text': { + text += part.text; ++ structuredContent.push({ type: 'text', text: part.text }); + break; + } + case 'tool-call': { +@@ -101,6 +108,14 @@ export function convertToMistralChatMessages( + } + case 'reasoning': { + text += part.text; ++ const native = part.providerOptions?.mistral ++ ?.thinking as MistralThinkChunk | undefined; ++ if (native?.type === 'thinking') { ++ hasNativeReasoning = true; ++ structuredContent.push(native); ++ break; ++ } ++ structuredContent.push({ type: 'text', text: part.text }); + break; + } + default: { +@@ -113,7 +128,7 @@ export function convertToMistralChatMessages( + + messages.push({ + role: 'assistant', +- content: text, ++ content: hasNativeReasoning ? structuredContent : text, + prefix: isLastMessage ? true : undefined, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }); +diff --git a/src/mistral-chat-language-model.ts b/src/mistral-chat-language-model.ts +index 7e4a7ab552f1b41b7074e1b3cada8a51d791268d..847d26f9dfe03572a969a122f8c96b8bbfda8066 100644 +--- a/src/mistral-chat-language-model.ts ++++ b/src/mistral-chat-language-model.ts +@@ -122,6 +122,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + stop: stopSequences, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + + // response format: + response_format: +@@ -201,9 +202,11 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + for (const part of choice.message.content) { + if (part.type === 'thinking') { + const reasoningText = extractReasoningContent(part.thinking); +- if (reasoningText.length > 0) { +- content.push({ type: 'reasoning', text: reasoningText }); +- } ++ content.push({ ++ type: 'reasoning', ++ text: reasoningText, ++ providerMetadata: { mistral: { thinking: part } }, ++ }); + } else if (part.type === 'text') { + if (part.text.length > 0) { + content.push({ type: 'text', text: part.text }); +@@ -278,6 +281,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + let isFirstChunk = true; + let activeText = false; + let activeReasoningId: string | null = null; ++ let activeThinking: z.infer | null = null; + + const generateId = this.generateId; + +@@ -326,20 +330,21 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + for (const part of delta.content) { + if (part.type === 'thinking') { + const reasoningDelta = extractReasoningContent(part.thinking); +- if (reasoningDelta.length > 0) { +- if (activeReasoningId == null) { +- // end any active text before starting reasoning +- if (activeText) { +- controller.enqueue({ type: 'text-end', id: '0' }); +- activeText = false; +- } +- +- activeReasoningId = generateId(); +- controller.enqueue({ +- type: 'reasoning-start', +- id: activeReasoningId, +- }); ++ activeThinking = mergeThinking(activeThinking, part); ++ if (activeReasoningId == null) { ++ // end any active text before starting reasoning ++ if (activeText) { ++ controller.enqueue({ type: 'text-end', id: '0' }); ++ activeText = false; + } ++ ++ activeReasoningId = generateId(); ++ controller.enqueue({ ++ type: 'reasoning-start', ++ id: activeReasoningId, ++ }); ++ } ++ if (reasoningDelta.length > 0) { + controller.enqueue({ + type: 'reasoning-delta', + id: activeReasoningId, +@@ -357,8 +362,12 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + controller.enqueue({ + type: 'reasoning-end', + id: activeReasoningId, ++ providerMetadata: { ++ mistral: { thinking: activeThinking }, ++ }, + }); + activeReasoningId = null; ++ activeThinking = null; + } + controller.enqueue({ type: 'text-start', id: '0' }); + activeText = true; +@@ -416,6 +425,9 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + controller.enqueue({ + type: 'reasoning-end', + id: activeReasoningId, ++ providerMetadata: { ++ mistral: { thinking: activeThinking }, ++ }, + }); + } + if (activeText) { +@@ -437,7 +449,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + } + + function extractReasoningContent( +- thinking: Array<{ type: string; text: string }>, ++ thinking: Array>, + ) { + return thinking + .filter(chunk => chunk.type === 'text') +@@ -445,6 +457,17 @@ function extractReasoningContent( + .join(''); + } + ++function mergeThinking( ++ current: z.infer | null, ++ next: z.infer, ++) { ++ if (current === null) return { ...next, thinking: [...next.thinking] }; ++ current.thinking.push(...next.thinking); ++ if (next.closed !== undefined) current.closed = next.closed; ++ if (next.signature !== undefined) current.signature = next.signature; ++ return current; ++} ++ + function extractTextContent(content: z.infer) { + if (typeof content === 'string') { + return content; +@@ -478,6 +501,32 @@ function extractTextContent(content: z.infer) { + return textContent.length ? textContent.join('') : undefined; + } + ++const mistralThinkingContentSchema = z.discriminatedUnion('type', [ ++ z.object({ ++ type: z.literal('text'), ++ text: z.string(), ++ }), ++ z.object({ ++ type: z.literal('tool_reference'), ++ tool: z.string(), ++ title: z.string(), ++ url: z.string().nullish(), ++ favicon: z.string().nullish(), ++ description: z.string().nullish(), ++ }), ++ z.object({ ++ type: z.literal('reference'), ++ reference_ids: z.array(z.union([z.string(), z.number().int()])), ++ }), ++]); ++ ++const mistralThinkChunkSchema = z.object({ ++ type: z.literal('thinking'), ++ thinking: z.array(mistralThinkingContentSchema), ++ closed: z.boolean().optional(), ++ signature: z.string().nullish(), ++}); ++ + const mistralContentSchema = z + .union([ + z.string(), +@@ -501,15 +550,7 @@ const mistralContentSchema = z + type: z.literal('reference'), + reference_ids: z.array(z.union([z.string(), z.number()])), + }), +- z.object({ +- type: z.literal('thinking'), +- thinking: z.array( +- z.object({ +- type: z.literal('text'), +- text: z.string(), +- }), +- ), +- }), ++ mistralThinkChunkSchema, + ]), + ), + ]) +diff --git a/src/mistral-chat-options.ts b/src/mistral-chat-options.ts +index 54b29c08517d348995b6ca093b11160e453d5c8b..de30c3e7d924889339e38b1067cb26e9ada05d11 100644 +--- a/src/mistral-chat-options.ts ++++ b/src/mistral-chat-options.ts +@@ -64,6 +64,11 @@ export const mistralLanguageModelOptions = z.object({ + * - `'none'`: Disable reasoning + */ + reasoningEffort: z.enum(['high', 'none']).optional(), ++ ++ /** ++ * A stable identifier used to route requests with shared prompt prefixes. ++ */ ++ promptCacheKey: z.string().optional(), + }); + + export type MistralLanguageModelOptions = z.infer< +diff --git a/src/mistral-chat-prompt.ts b/src/mistral-chat-prompt.ts +index 13f1dced55ac4be084128127a57fbdd58115bc28..172b11dde3dd326c2f3befd99237474ed8c79285 100644 +--- a/src/mistral-chat-prompt.ts ++++ b/src/mistral-chat-prompt.ts +@@ -23,7 +23,7 @@ export type MistralUserMessageContent = + + export interface MistralAssistantMessage { + role: 'assistant'; +- content: string; ++ content: string | Array; + prefix?: boolean; + tool_calls?: Array<{ + id: string; +@@ -32,6 +32,29 @@ export interface MistralAssistantMessage { + }>; + } + ++export type MistralAssistantMessageContent = ++ | { type: 'text'; text: string } ++ | MistralThinkChunk; ++ ++export type MistralThinkChunk = { ++ type: 'thinking'; ++ thinking: Array; ++ closed?: boolean; ++ signature?: string | null; ++}; ++ ++export type MistralThinkingContent = ++ | { type: 'text'; text: string } ++ | { ++ type: 'tool_reference'; ++ tool: string; ++ title: string; ++ url?: string | null; ++ favicon?: string | null; ++ description?: string | null; ++ } ++ | { type: 'reference'; reference_ids: Array }; ++ + export interface MistralToolMessage { + role: 'tool'; + name: string; From e7ecee5df24ae16d81d01f42f4b6d71ebd70711e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 23 Jul 2026 17:40:42 -0400 Subject: [PATCH 30/30] fix(core): isolate tool hook outcomes (#38571) --- packages/codemode/src/tool.ts | 2 +- packages/core/src/plugin/host.ts | 24 ++++++-------- packages/core/src/session/runner/llm.ts | 2 +- .../src/session/runner/publish-llm-event.ts | 31 +++++++++---------- packages/core/test/plugin.test.ts | 2 +- .../test/session-runner-tool-events.test.ts | 16 ++++++++-- 6 files changed, 40 insertions(+), 37 deletions(-) diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index d44e3d0f4e2..93b5a23598c 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -29,7 +29,7 @@ export type JsonSchema = { /** Either a validating Effect Schema or a render-only JSON Schema document. */ export type SchemaType = Schema.Decoder | JsonSchema -/** Executable tool tool exposed through CodeMode's `tools` object. */ +/** Executable tool exposed through CodeMode's `tools` object. */ export type Tool = { readonly _tag: "CodeModeTool" readonly description: string diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 7761aa18235..640fdea1482 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -394,19 +394,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }) } return toolHooks.hook.after((event) => { - // JS plugin boundary: marshal the canonical outcome out, copy mutations back. - const output: Record = { + // Decode first so plugin mutations cannot alias the canonical outcome. + const output = { tool: event.tool, sessionID: event.sessionID, agent: event.agent, messageID: event.messageID, callID: event.callID, input: event.input, - status: event.status, - content: event.content, - metadata: event.metadata, - outputPaths: event.outputPaths, - ...(event.status === "error" ? { error: event.error } : {}), + ...Schema.decodeUnknownSync(Tool.ExecuteAfterOutcome)(event), } return Reflect.apply(callback, undefined, [output]).pipe( Effect.tap(() => { @@ -417,16 +413,16 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool }) return Effect.sync(() => { if (event.status === "completed" && decoded.value.status === "completed") { - if (output.content !== event.content) event.content = decoded.value.content - if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata - if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths + event.content = decoded.value.content + event.metadata = decoded.value.metadata + event.outputPaths = decoded.value.outputPaths return } if (event.status === "error" && decoded.value.status === "error") { - if (output.error !== event.error) event.error = decoded.value.error - if (output.content !== event.content) event.content = decoded.value.content - if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata - if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths + event.error = decoded.value.error + event.content = decoded.value.content + event.metadata = decoded.value.metadata + event.outputPaths = decoded.value.outputPaths } }) }), diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 3d92a5c8134..ff00a8337bb 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -130,7 +130,7 @@ const layer = Layer.effect( // Durable publishes are serialized so tool fibers and step settlement never interleave // mid-event. const serialized = (effect: Effect.Effect) => publication.withPermit(effect) - const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error)) + const publish = (event: LLMEvent) => serialized(publisher.publish(event)) let overflowFailure: ProviderErrorEvent | undefined const providerStream = llm.stream(prepared.request).pipe( Stream.runForEach((event) => diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index f84253c39d2..d3d21fbfc82 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -1,5 +1,5 @@ -import { type LLMEvent, type ProviderMetadata, type ToolContent, type ToolResultValue } from "@opencode-ai/ai" -import { Effect, Schema } from "effect" +import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai" +import { Effect } from "effect" import { EventV2 } from "../../event" import { ModelV2 } from "../../model" import { SessionEvent } from "../event" @@ -12,7 +12,6 @@ import { Snapshot } from "../../snapshot" import { RelativePath } from "../../schema" import { SessionUsage } from "../usage" import { Tool } from "../../tool/tool" -import { MAX_BYTES } from "../../tool-output-store" import type { ToolRegistry } from "../../tool/registry" type Input = { @@ -28,9 +27,11 @@ const record = (value: unknown): Record => typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : { value } /** Derives canonical model content from a provider-hosted tool result. */ -const hostedContent = (result: ToolResultValue): readonly [ToolContent, ...ToolContent[]] => { - if (result.type === "content" && result.value.length > 0) - return result.value as unknown as readonly [ToolContent, ...ToolContent[]] +const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => { + if (result.type === "content") { + const content = Tool.nonEmpty(result.value) + if (content !== undefined) return content + } return [{ type: "text", text: Tool.stringify(result.value) }] } @@ -47,11 +48,8 @@ export const createLLMEventPublisher = (events: Pick() - const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => { - if (!tool.progress) return {} - const metadata = Tool.jsonMetadata(tool.progress, MAX_BYTES) - return metadata === undefined ? {} : { metadata } - } + const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => + tool.progress === undefined ? {} : { metadata: tool.progress } let assistantMessageID = input.assistantMessageID let stepStarted = false let stepFailed = false @@ -292,7 +290,7 @@ export const createLLMEventPublisher = (events: Pick { yield* ctx.tool .hook("execute.after", (event) => Effect.sync(() => { - if (event.status === "completed") event.content = [] as never + if (event.status === "completed") (event.content as unknown as unknown[]).splice(0) }), ) .pipe(Effect.asVoid) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 2ba096b306a..702999719c3 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -118,9 +118,7 @@ test("provider-executed success derives content and retains provider result stat test("interrupted progress metadata remains in the terminal failure snapshot", async () => { const { published, publisher } = capture("anthropic", { interruptProgress: true }) await Effect.runPromise(publisher.publish(call)) - const exit = await Effect.runPromiseExit( - publisher.progress(call.id, { phase: "visible" }), - ) + const exit = await Effect.runPromiseExit(publisher.progress(call.id, { phase: "visible" })) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" })) @@ -129,6 +127,18 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a }) }) +test("failure snapshot retains canonical progress above the default byte limit", async () => { + const { published, publisher } = capture("anthropic", { interruptProgress: true }) + await Effect.runPromise(publisher.publish(call)) + const detail = "x".repeat(60 * 1024) + await Effect.runPromiseExit(publisher.progress(call.id, { detail })) + await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" })) + + expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({ + metadata: { detail }, + }) +}) + test("failure before progress omits partial output fields", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(call))