Compare commits

..
Author SHA1 Message Date
Aiden Cline 85962e49b7 refactor(schema): rename message provider blobs to native 2026-09-20 21:41:03 -05:00
26 changed files with 254 additions and 398 deletions
-15
View File
@@ -5,7 +5,6 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "./editor/dom"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionOwnership } from "@/session/session-ownership"
import { decode64 } from "@/runtime/persistence/base64"
const withCategory = (category: string) => {
return (option: Omit<CommandOption, "category">): CommandOption => ({
@@ -24,13 +23,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const model = input.model ?? local.model
const modelCommand = withCategory(language.t("command.category.model"))
const agentCommand = withCategory(language.t("command.category.agent"))
const providerCommand = withCategory(language.t("command.category.provider"))
// Mirrors the TUI's `/connect`, which the Console's setup steps tell people to run.
const connectProvider = async () => {
const { DialogConnectProvider } = await import("@/providers/connect/dialog")
void dialog.show(() => <DialogConnectProvider directory={decode64(local.slug())} />)
}
const chooseModel = async () => {
const owner = sessionOwnership.capture()
@@ -70,13 +62,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
keybind: "shift+mod+d",
onSelect: () => model.variant.cycle(),
}),
providerCommand({
id: "provider.connect",
title: language.t("command.provider.connect"),
description: language.t("command.provider.connect.description"),
slash: "connect",
onSelect: connectProvider,
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
@@ -9,29 +9,10 @@ import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type Authorization = IntegrationOauthConnectOutput["data"]
// OpenCode Go and OpenCode Zen both bill through the OpenCode Console, so the
// Console sign-in is the connection method for both providers.
export const CONSOLE_INTEGRATION = "opencode"
export const CONSOLE_PROVIDERS = new Set(["opencode", "opencode-go"])
export function consoleIntegration(provider: string) {
return CONSOLE_PROVIDERS.has(provider) ? CONSOLE_INTEGRATION : provider
}
function hiddenDefaults(method: ProviderConnectMethod | undefined) {
return Object.fromEntries(
(method?.form ?? []).flatMap((field) =>
field.type !== "external" && field.hidden && field.default !== undefined ? [[field.key, field.default]] : [],
),
) as FormAnswer
}
export function createProviderConnectionController(options: {
provider: () => string
directory: () => string | undefined
onComplete: () => void
/** Picks the method to start without asking when the integration exposes several. */
autoSelect?: (methods: ProviderConnectMethod[]) => number | undefined
pollInterval?: number
}) {
const language = useLanguage()
@@ -62,24 +43,15 @@ export function createProviderConnectionController(options: {
formAnswer: undefined as FormAnswer | undefined,
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
error: undefined as string | undefined,
auto: false,
})
const polling = {
generation: 0,
timer: undefined as ReturnType<typeof setTimeout> | undefined,
disposed: false,
// An attempt the server still considers open; cancelled when the dialog goes away.
attempt: undefined as Authorization | undefined,
}
const currentMethod = createMemo(() =>
store.methodIndex === undefined ? undefined : methods().at(store.methodIndex),
)
const autoIndex = createMemo(() => {
if (integration.loading) return undefined
const values = methods()
if (values.length === 1) return 0
return options.autoSelect?.(values)
})
type Action =
| { type: "method.select"; index: number }
@@ -137,14 +109,6 @@ export function createProviderConnectionController(options: {
)
}
const cancelAttempt = () => {
const attempt = polling.attempt
polling.attempt = undefined
if (!attempt) return
void serverSDK.api.integration.oauth
.cancel({ integrationID: options.provider(), attemptID: attempt.attemptID, location: location() })
.catch(() => undefined)
}
const cancelPolling = () => {
polling.generation++
if (polling.timer === undefined) return
@@ -153,7 +117,6 @@ export function createProviderConnectionController(options: {
}
const finish = async () => {
cancelPolling()
polling.attempt = undefined
const ref = location()
data.location.integration.invalidate(ref)
data.location.provider.invalidate(ref)
@@ -177,7 +140,6 @@ export function createProviderConnectionController(options: {
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
polling.attempt = undefined
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
@@ -189,35 +151,26 @@ export function createProviderConnectionController(options: {
return
}
if (result.status.status === "failed") {
polling.attempt = undefined
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
polling.attempt = undefined
dispatch({ type: "auth.error", error: language.t("provider.connect.oauth.expired") })
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
}
const open = () => {
const url = store.authorization?.url
if (url) platform.openExternal(url)
}
const select = async (index: number, answer?: FormAnswer) => {
cancelPolling()
cancelAttempt()
const generation = polling.generation
const selected = methods()[index]
dispatch({ type: "method.select", index })
const visible = (selected.form ?? []).some((field) => field.type === "external" || !field.hidden)
if (visible && !answer) {
if (selected.form?.length && !answer) {
dispatch({ type: "auth.form" })
return
}
const merged = { ...hiddenDefaults(selected), ...answer }
if (selected.type === "key") {
dispatch({ type: "auth.answer", answer: Object.keys(merged).length ? merged : undefined })
dispatch({ type: "auth.answer", answer })
return
}
if (selected.type !== "oauth") return
@@ -230,11 +183,11 @@ export function createProviderConnectionController(options: {
.connect({
integrationID: options.provider(),
methodID: selected.id,
...(Object.keys(merged).length ? { answer: merged } : {}),
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => {
if (options.provider() === CONSOLE_INTEGRATION && platform.platform === "desktop") {
if (options.provider() === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
response.data.url = url.href
@@ -242,39 +195,16 @@ export function createProviderConnectionController(options: {
return { ok: true as const, authorization: response.data }
})
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) {
if (result.ok)
void serverSDK.api.integration.oauth
.cancel({
integrationID: options.provider(),
attemptID: result.authorization.attemptID,
location: location(),
})
.catch(() => undefined)
return
}
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
})
dispatch({ type: "auth.error", error: String(result.error) })
return
}
polling.attempt = result.authorization
dispatch({ type: "auth.complete", authorization: result.authorization })
// Same as `opencode auth login`: hand the user straight to the browser instead of
// asking them to click a link and retype a code.
platform.openExternal(result.authorization.url)
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
}
const retry = () => {
const index = store.methodIndex
if (index === undefined) return
void select(index, store.formAnswer)
}
const reset = () => {
cancelPolling()
cancelAttempt()
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
@@ -306,16 +236,15 @@ export function createProviderConnectionController(options: {
return undefined
}
let auto = false
createEffect(() => {
const index = autoIndex()
if (store.auto || index === undefined) return
setStore("auto", true)
void select(index)
if (auto || integration.loading || methods().length !== 1) return
auto = true
void select(0)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
cancelAttempt()
})
return {
@@ -325,19 +254,11 @@ export function createProviderConnectionController(options: {
currentMethod,
methodIndex: () => store.methodIndex,
authorization: () => store.authorization,
// True while nothing useful can be shown yet: the integration is loading, a method is
// about to be picked automatically, or the authorization request is in flight.
busy: () =>
integration.loading ||
(store.methodIndex === undefined && !store.auto && autoIndex() !== undefined) ||
store.state === "pending",
auth: {
state: () => store.state,
error: () => store.error,
select,
reset,
retry,
open,
connectKey,
completeCode,
},
+101 -183
View File
@@ -8,23 +8,16 @@ import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import { type Component, createMemo, createUniqueId, For, type JSX, Match, onMount, Show, Switch } from "solid-js"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/runtime/platform/external-link"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { useProviders } from "@/providers/catalog/providers"
import { useIntegrations } from "@/providers/catalog/integrations"
import { CustomProviderForm } from "@/providers/credentials/dialog"
import { decode64 } from "@/runtime/persistence/base64"
import {
CONSOLE_PROVIDERS,
consoleIntegration,
createProviderConnectionController,
type ProviderConnectMethod,
} from "./controller"
import { createProviderConnectionController, type ProviderConnectMethod } from "./controller"
const CUSTOM_ID = "_custom"
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
@@ -265,39 +258,13 @@ function ProviderConnection(props: {
const dialog = useDialog()
const params = useParams()
const language = useLanguage()
const data = useData()
const global = useGlobal()
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const directory = () => props.directory ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const integrationID = () => consoleIntegration(props.provider)
const isConsole = () => CONSOLE_PROVIDERS.has(props.provider)
const controller = createProviderConnectionController({
provider: integrationID,
provider: () => props.provider,
directory,
autoSelect: (methods) => {
if (!isConsole()) return undefined
const index = methods.findIndex((method) => method.type === "oauth")
return index === -1 ? undefined : index
},
onComplete: () => {
// The picker only lists the newest model per family by default, which hides most of
// what a new connection just unlocked. Show everything the connected integration offers.
// Console sign-in returns Go models inside the `opencode` provider, so this covers Go too.
const linked = (data.location.provider.list(location()) ?? []).filter(
(item) => item.id === props.provider || item.integrationID === integrationID(),
)
const ids = new Set(linked.map((item) => item.id))
global.models.show(
(data.location.model.list(location()) ?? [])
.filter((model) => ids.has(model.providerID) && model.status !== "deprecated")
.map((model) => ({ providerID: model.providerID, modelID: model.id })),
)
dialog.close()
showToast({
variant: "success",
@@ -309,11 +276,7 @@ function ProviderConnection(props: {
})
const provider = createMemo(() => ({
id: props.provider,
name:
integrations.list().find((item) => item.id === props.provider)?.name ??
providers.all().get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
}))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
@@ -335,13 +298,6 @@ function ProviderConnection(props: {
: undefined,
}
}
const code = createMemo(() => {
const instructions = controller.authorization()?.instructions
if (instructions?.includes(":")) return instructions.split(":").pop()?.trim()
return instructions
})
const keyIndex = () => controller.methods().findIndex((method) => method.type === "key")
const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth")
function AuthFormView() {
const [formStore, setFormStore] = createStore({
@@ -351,7 +307,7 @@ function ProviderConnection(props: {
const fields = createMemo<StringForm[]>(() => {
const value = controller.currentMethod()
return (value?.form ?? []).flatMap((field) => (field.type === "string" && !field.hidden ? [field] : []))
return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
})
const matches = (field: StringForm, value: Record<string, string>) => {
return (field.when ?? []).every((condition) => {
@@ -408,7 +364,7 @@ function ProviderConnection(props: {
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<Switch>
<Match when={item()?.field.options === undefined}>
<TextField
@@ -466,13 +422,7 @@ function ProviderConnection(props: {
}
function goBack() {
// The API key path for the Console is an escape hatch below the sign-in flow, so
// "back" returns to the sign-in rather than leaving the provider.
if (isConsole() && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) {
void controller.auth.select(oauthIndex())
return
}
if (!isConsole() && controller.methods().length > 1 && controller.methodIndex() !== undefined) {
if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
controller.auth.reset()
return
}
@@ -513,31 +463,6 @@ function ProviderConnection(props: {
)
}
function StatusRow(input: { children: JSX.Element }) {
return (
<div class="flex items-center gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Spinner class="size-4 shrink-0 text-v2-icon-icon-muted" />
<span>{input.children}</span>
</div>
)
}
function ErrorRow() {
return (
<div class="flex flex-col items-start gap-3">
<div class="flex items-start gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<Icon name="circle-ban-sign" size="small" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
<span role="alert">
{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
</span>
</div>
<Button variant="neutral" onClick={() => controller.auth.retry()}>
{language.t("common.retry")}
</Button>
</div>
)
}
function ApiAuthView() {
let apiKey: HTMLInputElement | undefined
const errorID = createUniqueId()
@@ -569,17 +494,22 @@ function ProviderConnection(props: {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={isConsole()}
when={provider().id === "opencode"}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div>
{language.t("provider.connect.console.apiKey.description")}{" "}
<ExternalLink
href="https://opencode.ai/console"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.console.apiKey.link")}
</ExternalLink>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Show>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@@ -644,10 +574,13 @@ function ProviderConnection(props: {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>{language.t("provider.connect.oauth.code.description", { provider: provider().name })}</div>
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
@@ -680,60 +613,34 @@ function ProviderConnection(props: {
}
function OAuthAutoView() {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t(
isConsole() ? "provider.connect.console.description" : "provider.connect.oauth.auto.description",
{ provider: provider().name },
)}
</div>
<StatusRow>{language.t("provider.connect.status.waiting")}</StatusRow>
<div class="flex flex-wrap items-center gap-2">
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
</div>
<Show when={code()}>
{(value) => (
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
description={language.t("provider.connect.oauth.auto.confirmationCode.description")}
class="font-mono"
value={value()}
readOnly
copyable
/>
)}
</Show>
</div>
)
}
const code = createMemo(() => {
const instructions = controller.authorization()?.instructions
if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim()
}
return instructions
})
// Deliberately quiet: most people should never need a key, so it only appears after
// opening "Advanced".
function ConsoleAdvanced() {
const [store, setStore] = createStore({ open: false })
return (
<div class="mt-auto flex flex-col items-end gap-1 px-3 pt-4 text-[11px] leading-4 text-v2-text-text-muted">
<button
type="button"
class="rounded-xs px-1 hover:text-v2-text-text-base focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
aria-expanded={store.open}
onClick={() => setStore("open", (open) => !open)}
>
{language.t("provider.connect.console.advanced")}
</button>
<Show when={store.open}>
<button
type="button"
data-action="provider-connect-api-key"
class="rounded-xs px-1 underline decoration-v2-border-border-base underline-offset-2 hover:text-v2-text-text-base focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
onClick={() => void controller.auth.select(keyIndex())}
>
{language.t("provider.connect.console.apiKey.switch")}
</button>
</Show>
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={controller.authorization()!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</div>
</div>
)
}
@@ -754,42 +661,53 @@ function ProviderConnection(props: {
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<Switch>
<Match when={controller.busy()}>
<div class="px-3">
<StatusRow>
{language.t(
isConsole() && controller.methodIndex() !== undefined
? "provider.connect.console.opening"
: "provider.connect.status.inProgress",
)}
</StatusRow>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="px-3">
<ErrorRow />
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
<Show when={isConsole() && controller.currentMethod()?.type !== "key" && keyIndex() !== -1}>
<ConsoleAdvanced />
</Show>
<div>
<Switch>
<Match when={controller.loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
</div>
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.currentMethod()?.type === "oauth"}>
<Switch>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
</div>
</div>
)
-18
View File
@@ -87,7 +87,6 @@ export const dict = {
"command.project.index": "Switch to project {{index}}",
"command.project.copyID": "Copy Project ID",
"command.provider.connect": "Connect provider",
"command.provider.connect.description": "Sign in to OpenCode Go, OpenCode Console, or another model provider",
"command.server.switch": "Switch server",
"command.settings.open": "Open settings",
"command.session.previous": "Previous session",
@@ -226,22 +225,6 @@ export const dict = {
"provider.connect.oauth.auto.visit.suffix":
" and enter the code below to connect your account and use {{provider}} models in OpenCode.",
"provider.connect.oauth.auto.confirmationCode": "Confirmation code",
"provider.connect.oauth.auto.confirmationCode.description":
"Check that your browser shows the same code before you authorize.",
"provider.connect.oauth.auto.description":
"Your browser opens so you can sign in to {{provider}}. Come back here when you are done.",
"provider.connect.oauth.code.description":
"Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.",
"provider.connect.oauth.openBrowser": "Open browser",
"provider.connect.oauth.expired": "Authorization expired",
"provider.connect.console.description":
"Sign in with your OpenCode Console account. Your browser opens to the Console, where you pick a workspace and select Authorize.",
"provider.connect.console.opening": "Opening your browser…",
"provider.connect.console.advanced": "Advanced",
"provider.connect.console.apiKey.switch": "Connect with an API key or service account instead",
"provider.connect.console.apiKey.description":
"Paste an API key for a service account. You create service accounts in the OpenCode Console under Keys.",
"provider.connect.console.apiKey.link": "Open the Console",
"provider.connect.toast.connected.title": "{{provider}} connected",
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
@@ -319,7 +302,6 @@ export const dict = {
"common.connect": "Connect",
"common.disconnect": "Disconnect",
"common.continue": "Continue",
"common.retry": "Try again",
"common.submit": "Submit",
"common.save": "Save",
"common.saving": "Saving…",
+1 -20
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
@@ -98,25 +98,6 @@ function createGlobalModels() {
set: setStore,
ready,
recent: () => recent()!,
// Marks models visible in the picker regardless of the "latest per family" default.
show(models: ReadonlyArray<{ providerID: string; modelID: string }>) {
const seen = new Map(store.user.map((item, index) => [`${item.providerID}:${item.modelID}`, index]))
batch(() => {
for (const model of models) {
const index = seen.get(`${model.providerID}:${model.modelID}`)
if (index !== undefined) {
setStore("user", index, "visibility", "show")
continue
}
seen.set(`${model.providerID}:${model.modelID}`, store.user.length)
setStore("user", store.user.length, {
providerID: model.providerID,
modelID: model.modelID,
visibility: "show",
})
}
})
},
}
}
+18 -18
View File
@@ -509,12 +509,12 @@ export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention }
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
export type SessionMessageAssistantText = { type: "text"; text: string; native?: SessionMessageProviderState }
export type SessionMessageAssistantReasoning = {
type: "reasoning"
text: string
state?: SessionMessageProviderState
native?: SessionMessageProviderState
time?: { created: number; completed?: number }
}
@@ -1354,12 +1354,12 @@ export type SessionToolCalled = {
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantText1 = { type: "text"; text: string; native?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
native?: SessionMessageProviderState1
time?: { created: number; completed?: number }
}
@@ -1762,7 +1762,7 @@ export type SessionMessageCompactionCompleted = {
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
native?: SessionMessageProviderState
summary: string
recent: string
providerContext?: SessionProviderContext
@@ -2228,7 +2228,7 @@ export type SessionMessageAssistant = {
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState
native?: SessionMessageProviderState
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
@@ -3102,11 +3102,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3180,7 +3180,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3214,7 +3214,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
@@ -3419,11 +3419,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3497,7 +3497,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3531,7 +3531,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
@@ -3736,11 +3736,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3814,7 +3814,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3848,7 +3848,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
+7 -7
View File
@@ -846,7 +846,7 @@ export function createData(config: CreateDataInput) {
existing.error = undefined
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.native = undefined
existing.time.created = event.data.started
existing.time.streamed = undefined
existing.time.completed = undefined
@@ -880,7 +880,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.native = event.data.providerState
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
@@ -892,7 +892,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish ?? "error"
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.native = event.data.providerState
assistant.error = event.data.error
assistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
@@ -984,7 +984,7 @@ export function createData(config: CreateDataInput) {
assistant.content.push({
type: "reasoning",
text: "",
state: event.data.state,
native: event.data.state,
time: { created: event.created },
})
})
@@ -998,7 +998,7 @@ export function createData(config: CreateDataInput) {
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
reasoning.text = event.data.text
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) reasoning.state = event.data.state
if (event.data.state !== undefined) reasoning.native = event.data.state
})
return
case "session.retry.scheduled":
@@ -1105,7 +1105,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
@@ -1120,7 +1120,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
@@ -135,7 +135,7 @@ test.each(["started", "cancelled", "failed"])(
status: "completed",
summary: "Summary",
model,
providerState,
native: providerState,
providerContext,
cost: 0.01,
tokens,
+2
View File
@@ -46,6 +46,7 @@ import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
import m47 from "./migration/20260920120000_message_native.js"
export const migrations = [
m00,
@@ -95,4 +96,5 @@ export const migrations = [
m44,
m45,
m46,
m47,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,33 @@
import { Effect, Predicate, Schema } from "effect"
import { SessionMessage } from "../../session/message.js"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260920120000_message_native",
up(tx) {
return Effect.gen(function* () {
const rows = yield* tx.all<{ id: string; type: string; data: string }>(
`SELECT id, type, data FROM session_message WHERE type IN ('assistant', 'compaction')`,
)
yield* Effect.forEach(rows, (row) =>
Effect.gen(function* () {
const stored = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(row.data)
if (!Predicate.isObject(stored)) return
const next = SessionMessage.persisted({ ...stored, type: row.type })
if (!Predicate.isObject(next)) return
const data = Object.fromEntries(Object.entries(next).filter(([key]) => key !== "type"))
if (JSON.stringify(data) === JSON.stringify(stored)) return
yield* tx.run(
`UPDATE session_message SET data = '${quote(JSON.stringify(data))}' WHERE id = '${quote(row.id)}'`,
)
}),
)
})
},
}
function quote(value: string) {
return value.replaceAll("'", "''")
}
export default migration
+1 -1
View File
@@ -60,7 +60,7 @@ export const latestCompaction = Effect.fnUntraced(function* (
})
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
Effect.tap((message) =>
SessionProviderContext.isCheckpoint(message)
? SessionProviderContext.validate(message.providerContext)
+1 -1
View File
@@ -125,7 +125,7 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* new LifecycleConflict({ id })
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const message = decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
const base = { id, sessionID, time: { created: message.time.created }, delivery }
if (message.type === "user")
return User.make({
+8 -8
View File
@@ -222,7 +222,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.error = undefined
draft.finish = undefined
draft.rawFinish = undefined
draft.providerState = undefined
draft.native = undefined
draft.time.created = DateTime.makeUnsafe(event.data.started)
draft.time.streamed = undefined
draft.time.completed = undefined
@@ -263,7 +263,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.native = castDraft(event.data.providerState)
draft.cost = event.data.cost
draft.tokens = event.data.tokens
projectTerminalSnapshot(draft, event)
@@ -274,7 +274,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish ?? "error"
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.native = castDraft(event.data.providerState)
draft.error = castDraft(event.data.error)
draft.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
@@ -294,7 +294,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
const match = latestText(draft)
if (match) {
match.text = event.data.text
match.state = castDraft(event.data.state)
match.native = castDraft(event.data.state)
}
})
},
@@ -382,7 +382,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "",
state: event.data.state,
native: event.data.state,
time: { created },
}),
),
@@ -395,7 +395,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? created, completed: created }
if (event.data.state !== undefined) match.state = event.data.state
if (event.data.state !== undefined) match.native = event.data.state
}
})
},
@@ -431,7 +431,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata ? { ...current.metadata, ...event.metadata } : current.metadata,
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
@@ -448,7 +448,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata,
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
+1 -1
View File
@@ -228,7 +228,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
const updateMessage = (message: SessionMessage.Info) => {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
+3 -1
View File
@@ -107,7 +107,9 @@ const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
const message = yield* decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
Effect.orDie,
)
if (message.type !== "assistant" || !message.snapshot?.start) continue
for (const file of message.snapshot.files ?? [])
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
@@ -162,7 +162,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
text: item.text,
// Text can carry provider-bound state (e.g. Gemini thought signatures),
// which is only replayable against the model that produced it.
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.native) : undefined,
},
]
// Let the destination adapter handle readable reasoning after a model/provider switch.
@@ -172,7 +172,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
{
type: "reasoning",
text: item.text,
providerMetadata: providerMetadata(providerMetadataKey, item.state),
providerMetadata: providerMetadata(providerMetadataKey, item.native),
},
]
: item.text.length > 0
+3 -3
View File
@@ -269,13 +269,13 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
return {
...content,
text: redact("text", message.id, content.text),
state: content.state ? { redacted: `text-state:${message.id}` } : undefined,
native: content.native ? { redacted: `text-native:${message.id}` } : undefined,
}
if (content.type === "reasoning")
return {
...content,
text: redact("reasoning", message.id, content.text),
state: content.state ? { redacted: `reasoning-state:${message.id}` } : undefined,
native: content.native ? { redacted: `reasoning-native:${message.id}` } : undefined,
}
return {
...content,
@@ -299,7 +299,7 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
summary: redact("compaction-summary", message.id, message.summary),
recent: redact("compaction-recent", message.id, message.recent),
...(message.status === "completed"
? { providerState: metadata("compaction-provider-state", message.id, message.providerState) }
? { native: metadata("compaction-native", message.id, message.native) }
: {}),
}
}
+4 -4
View File
@@ -1272,7 +1272,7 @@ describe("SessionTransfer", () => {
const runningCompactionID = SessionMessage.ID.create()
const completedCompactionID = SessionMessage.ID.create()
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
const providerState = { responseId: "summary-response" }
const native = { responseId: "summary-response" }
yield* transfer.import({
data: {
@@ -1328,7 +1328,7 @@ describe("SessionTransfer", () => {
status: "completed",
reason: "manual",
model,
providerState,
native,
summary: "summary",
recent: "recent",
time: { created: DateTime.makeUnsafe(9) },
@@ -1345,10 +1345,10 @@ describe("SessionTransfer", () => {
completedCompactionID,
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, providerState })
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, native })
expect((yield* transfer.export({ sessionID, sanitize: true })).messages.at(-1)).toMatchObject({
model,
providerState: { redacted: `compaction-provider-state:${completedCompactionID}` },
native: { redacted: `compaction-native:${completedCompactionID}` },
})
}),
)
@@ -426,7 +426,7 @@ it.live("compaction hooks supply the summary instead of provider compaction", ()
status: "completed",
summary: "## Objective\n- hooked summary",
recent: "",
providerState: { responseId: "plugin" },
native: { responseId: "plugin" },
metadata: { plugin: "custom" },
tokens: { input: 10, output: 5 },
})
+2 -2
View File
@@ -690,7 +690,7 @@ describe("SessionProjector", () => {
type: "assistant",
finish: "stop",
rawFinish: "stop_sequence",
providerState: { response: "ended" },
native: { response: "ended" },
cost: Money.USD.make(1),
tokens: { input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } },
snapshot: { end: "snap_ended", files: ["src/ended.ts"] },
@@ -700,7 +700,7 @@ describe("SessionProjector", () => {
type: "assistant",
finish: "content-filter",
rawFinish: "blocked",
providerState: { response: "failed" },
native: { response: "failed" },
error: { type: "provider.invalid-request", message: "Failed" },
snapshot: { end: "snap_failed", files: ["src/failed.ts"] },
time: { completed: created },
@@ -72,7 +72,7 @@ describe("toLLMMessages", () => {
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "",
state: { signature: "sig_1" },
native: { signature: "sig_1" },
}),
]),
],
@@ -711,7 +711,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Think",
state: { signature: "sig_1" },
native: { signature: "sig_1" },
}),
SessionMessage.AssistantTool.make({
type: "tool",
@@ -860,7 +860,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Think",
state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
native: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
}),
],
time: { created, completed: created },
@@ -891,7 +891,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Think",
state: { signature: "signed" },
native: { signature: "signed" },
}),
],
time: { created, completed: created },
@@ -918,7 +918,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Partial thought",
state: { itemId: "rs_failed", reasoningEncryptedContent: null },
native: { itemId: "rs_failed", reasoningEncryptedContent: null },
}),
SessionMessage.AssistantTool.make({
type: "tool",
@@ -1016,7 +1016,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Visible thought",
state: { signature: "sig_old" },
native: { signature: "sig_old" },
}),
SessionMessage.AssistantTool.make({
type: "tool",
@@ -1110,7 +1110,7 @@ Recent work
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "Visible thought",
state: { reasoningEncryptedContent: "encrypted" },
native: { reasoningEncryptedContent: "encrypted" },
}),
],
time: { created, completed: created },
@@ -1140,7 +1140,7 @@ Recent work
SessionMessage.AssistantText.make({
type: "text",
text: "Checking.",
state: { phase: "commentary" },
native: { phase: "commentary" },
}),
],
error: { type: "provider.unknown", message: "Interrupted after commentary" },
@@ -1171,7 +1171,7 @@ Recent work
SessionMessage.AssistantText.make({
type: "text",
text: "Checking.",
state: { phase: "commentary" },
native: { phase: "commentary" },
}),
],
time: { created, completed: created },
+3 -3
View File
@@ -2558,7 +2558,7 @@ describe("SessionRunnerLLM", () => {
expect(s.executions).toEqual(["x".repeat(4_000)])
expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({
model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant },
providerState: { responseId: "summary" },
native: { responseId: "summary" },
})
// Compare wire content without the cache breakpoints that move to the new final message.
@@ -4965,7 +4965,7 @@ describe("SessionRunnerLLM", () => {
type: "assistant",
finish: "stop",
rawFinish: "end_turn",
providerState: { responseId: "response-1", serviceTier: "priority" },
native: { responseId: "response-1", serviceTier: "priority" },
content: [Expected.text("Complete")],
},
])
@@ -4996,7 +4996,7 @@ describe("SessionRunnerLLM", () => {
type: "assistant",
finish: "content-filter",
rawFinish: "SAFETY",
providerState: {
native: {
responseId: "response-blocked",
refusal: { category: "safety", explanation: "Prompt blocked" },
},
+1 -1
View File
@@ -601,7 +601,7 @@ export namespace Compaction {
...Base,
reason: Started.data.fields.reason,
model: SessionMessage.CompactionCompleted.fields.model,
providerState: SessionMessage.CompactionCompleted.fields.providerState,
providerState: SessionMessage.CompactionCompleted.fields.native,
providerContext: SessionMessage.CompactionCompleted.fields.providerContext,
text: Schema.String,
recent: Schema.String,
+27 -5
View File
@@ -1,6 +1,6 @@
export * as SessionMessage from "./session-message.js"
import { Schema } from "effect"
import { Predicate, Schema } from "effect"
import { SessionProviderContext } from "./session-provider-context.js"
import { optional } from "./schema.js"
import { Content } from "./tool.js"
@@ -177,14 +177,14 @@ export interface AssistantText extends Schema.Schema.Type<typeof AssistantText>
export const AssistantText = Schema.Struct({
type: Schema.tag("text"),
text: Schema.String,
state: ProviderState.pipe(optional),
native: ProviderState.pipe(optional),
}).annotate({ identifier: "Session.Message.Assistant.Text" })
export interface AssistantReasoning extends Schema.Schema.Type<typeof AssistantReasoning> {}
export const AssistantReasoning = Schema.Struct({
type: Schema.tag("reasoning"),
text: Schema.String,
state: ProviderState.pipe(optional),
native: ProviderState.pipe(optional),
time: Schema.Struct({
created: DateTimeUtcFromMillis,
completed: DateTimeUtcFromMillis.pipe(optional),
@@ -222,7 +222,7 @@ export const Assistant = Schema.Struct({
}).pipe(optional),
finish: FinishReason.pipe(optional),
rawFinish: Schema.String.pipe(optional),
providerState: ProviderState.pipe(optional),
native: ProviderState.pipe(optional),
cost: Money.USD.pipe(optional),
tokens: TokenUsage.Info.pipe(optional),
error: SessionError.Error.pipe(optional),
@@ -258,7 +258,7 @@ export const CompactionCompleted = Schema.Struct({
status: Schema.tag("completed"),
reason: Schema.Literals(["auto", "manual"]),
model: Model.Ref.pipe(optional),
providerState: ProviderState.pipe(optional),
native: ProviderState.pipe(optional),
summary: Schema.String,
recent: Schema.String,
providerContext: SessionProviderContext.Info.pipe(optional),
@@ -318,3 +318,25 @@ export type Info =
| Compaction
| Idle
export type Type = Info["type"]
/** Reads messages stored before provider blobs were renamed to `native`. Tool parts are unchanged. */
export function persisted(input: unknown) {
if (!Predicate.isObject(input)) return input
const message =
input.type === "assistant" || input.type === "compaction" ? rename(input, "providerState", "native") : input
if (message.type !== "assistant" || !Array.isArray(message.content)) return message
return {
...message,
content: message.content.map((part) => {
if (!Predicate.isObject(part) || (part.type !== "text" && part.type !== "reasoning")) return part
return rename(part, "state", "native")
}),
}
}
function rename(record: Record<string, unknown>, from: string, to: string) {
if (record[from] === undefined || record[to] !== undefined) return record
const value = record[from]
const rest = Object.fromEntries(Object.entries(record).filter(([key]) => key !== from))
return { ...rest, [to]: value }
}
@@ -257,8 +257,8 @@ describe("contract hygiene", () => {
text: "hello",
})
expect(
SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "thinking", state: { id: "opaque" } }),
).toEqual({ type: "reasoning", text: "thinking", state: { id: "opaque" } })
SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "thinking", native: { id: "opaque" } }),
).toEqual({ type: "reasoning", text: "thinking", native: { id: "opaque" } })
expect(
SessionMessage.AssistantTool.make({
type: "tool",
+12 -2
View File
@@ -23,14 +23,24 @@ test("assistant terminal diagnostics remain optional and round trip", () => {
...assistant,
finish: "content-filter",
rawFinish: "SAFETY",
providerState: { promptFeedback: { blockReason: "SAFETY" } },
native: { promptFeedback: { blockReason: "SAFETY" } },
}),
),
).toMatchObject({
finish: "content-filter",
rawFinish: "SAFETY",
providerState: { promptFeedback: { blockReason: "SAFETY" } },
native: { promptFeedback: { blockReason: "SAFETY" } },
})
const legacy = SessionMessage.persisted({
...assistant,
providerState: { promptFeedback: { blockReason: "SAFETY" } },
content: [{ type: "text", text: "hello", state: { signature: "sig" } }],
})
expect(decode(legacy)).toMatchObject({
native: { promptFeedback: { blockReason: "SAFETY" } },
content: [{ type: "text", native: { signature: "sig" } }],
})
expect(encode(decode(legacy))).not.toHaveProperty("providerState")
})
test("failed steps only override the assistant finish for content filters", () => {