mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-21 16:17:35 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85962e49b7 |
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,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",
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user