mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 10:14:49 +00:00
fix(tui): restore agent and model selection parity (#47260)
This commit is contained in:
parent
e26cbb935e
commit
acb462a173
17 changed files with 795 additions and 167 deletions
|
|
@ -6,6 +6,7 @@
|
|||
import type {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
ConfigEntry,
|
||||
FormCancelInput,
|
||||
FormInfo,
|
||||
FormReplyInput,
|
||||
|
|
@ -84,6 +85,7 @@ type LocationData = {
|
|||
vcs?: VcsInfo
|
||||
agent?: AgentInfo[]
|
||||
command?: CommandInfo[]
|
||||
config?: ConfigEntry[]
|
||||
integration?: IntegrationInfo[]
|
||||
mcpServer?: McpServer[]
|
||||
mcpResource?: McpResource[]
|
||||
|
|
@ -1215,6 +1217,11 @@ export function createData(config: CreateDataInput) {
|
|||
)
|
||||
break
|
||||
case "config.updated":
|
||||
result.location.config.invalidate(location)
|
||||
if (result.location.config.list(location) !== undefined || sync.has(`location.config:${locationKey(location)}`))
|
||||
refresh(() => result.location.config.sync(location))
|
||||
refresh(() => result.location.websearch.refresh(location))
|
||||
break
|
||||
case "websearch.updated":
|
||||
refresh(() => result.location.websearch.refresh(location))
|
||||
break
|
||||
|
|
@ -1790,6 +1797,7 @@ export function createData(config: CreateDataInput) {
|
|||
result.location.vcs.invalidate(location)
|
||||
result.location.agent.invalidate(location)
|
||||
result.location.command.invalidate(location)
|
||||
result.location.config.invalidate(location)
|
||||
result.location.integration.invalidate(location)
|
||||
result.location.mcp.server.invalidate(location)
|
||||
result.location.mcp.resource.invalidate(location)
|
||||
|
|
@ -1803,6 +1811,10 @@ export function createData(config: CreateDataInput) {
|
|||
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
|
||||
agent: locationResource("agent", (location) => api().agent.list({ location })),
|
||||
command: locationResource("command", (location) => api().command.list({ location })),
|
||||
config: locationResource("config", async (location) => ({
|
||||
location: { directory: location.directory, workspaceID: location.workspace },
|
||||
data: await api().config.get({ location }),
|
||||
})),
|
||||
integration: locationResource("integration", (location) => api().integration.list({ location })),
|
||||
mcp: {
|
||||
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,56 @@ import { createRoot } from "solid-js"
|
|||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
||||
|
||||
test("config reads and update refreshes are opt-in", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const requests: string[] = []
|
||||
const location = { directory: "/project" }
|
||||
let model = "provider/first"
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname === "/api/location") return Response.json(location)
|
||||
if (url.pathname === "/api/config") return Response.json([{ type: "document", info: { model } }])
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return Response.json({ location, data: { resources: [], templates: [] } })
|
||||
return Response.json({ location, data: [] })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: location.directory,
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const event: OpenCodeEvent = { id: "evt_config", created: 1, type: "config.updated", location, data: {} }
|
||||
try {
|
||||
await setup.data.location.sync()
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
expect(requests).not.toContain("/api/config")
|
||||
|
||||
await setup.data.location.config.sync()
|
||||
expect(requests.filter((path) => path === "/api/config")).toHaveLength(1)
|
||||
model = "provider/second"
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
await setup.data.location.config.sync()
|
||||
expect(requests.filter((path) => path === "/api/config")).toHaveLength(2)
|
||||
expect(setup.data.location.config.list()).toEqual([{ type: "document", info: { model } }])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const reported = Promise.withResolvers<unknown>()
|
||||
|
|
|
|||
|
|
@ -737,6 +737,7 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
|||
slash: { name: "new", aliases: ["clear"] },
|
||||
run: () => {
|
||||
const model = local.model.current()
|
||||
const agent = local.agent.current()
|
||||
const current =
|
||||
route.data.type === "session"
|
||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||
|
|
@ -750,6 +751,7 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
|||
location.error?.location,
|
||||
),
|
||||
})
|
||||
if (agent) local.agent.set(agent.id)
|
||||
if (model) local.model.set(model)
|
||||
dialog.clear()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,22 +7,33 @@ export function DialogVariant() {
|
|||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() =>
|
||||
local.model.variant.list().map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
const options = createMemo(() => [
|
||||
{
|
||||
value: "default",
|
||||
title: "Default",
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(variant)
|
||||
local.model.variant.set(undefined)
|
||||
},
|
||||
})),
|
||||
)
|
||||
},
|
||||
...local.model.variant
|
||||
.list()
|
||||
.filter((variant) => variant !== "default")
|
||||
.map((variant) => ({
|
||||
value: variant,
|
||||
title: variant,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.variant.set(variant)
|
||||
},
|
||||
})),
|
||||
])
|
||||
|
||||
return (
|
||||
<DialogSelect<string>
|
||||
options={options()}
|
||||
title={"Select variant"}
|
||||
current={local.model.variant.current()}
|
||||
current={local.model.variant.current() ?? "default"}
|
||||
flat={true}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ import { useConnected } from "../use-connected"
|
|||
import { useToast } from "../../ui/toast"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { useConfig } from "../../config"
|
||||
import { usePromptMove } from "./move"
|
||||
import { resolvePastedAttachments } from "./local-attachment"
|
||||
|
|
@ -190,7 +189,6 @@ export function Prompt(props: PromptProps) {
|
|||
const leader = Keymap.useLeaderActive()
|
||||
const muted = () => leader() || props.muted
|
||||
const local = useLocal()
|
||||
const args = useArgs()
|
||||
const paths = useTuiPaths()
|
||||
const terminalEnvironment = useTuiTerminalEnvironment()
|
||||
const clipboard = useClipboard()
|
||||
|
|
@ -412,18 +410,6 @@ export function Prompt(props: PromptProps) {
|
|||
),
|
||||
)
|
||||
|
||||
// Initialize agent/model/variant from the durable V2 Session state.
|
||||
let syncedSessionID: string | undefined
|
||||
createEffect(() => {
|
||||
const sessionID = props.sessionID
|
||||
if (!sessionID || sessionID === syncedSessionID || !local.model.ready) return
|
||||
const session = data.session.get(sessionID)
|
||||
if (!session) return
|
||||
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
|
||||
if (agent && !args.agent) local.agent.set(agent.id)
|
||||
syncedSessionID = sessionID
|
||||
})
|
||||
|
||||
const promptCommands = createMemo(() =>
|
||||
[
|
||||
{
|
||||
|
|
@ -1265,6 +1251,23 @@ export function Prompt(props: PromptProps) {
|
|||
}
|
||||
|
||||
const target = sessionID
|
||||
const prepareAgent = async () => {
|
||||
if (!session) {
|
||||
await data.session.sync(target)
|
||||
session = data.session.get(target)
|
||||
}
|
||||
if (session?.agent !== agent.id) {
|
||||
await client.api.session.switchAgent({ sessionID: target, agent: agent.id })
|
||||
}
|
||||
}
|
||||
const commitModel = () => {
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
const cancelCommit = local.model.trackSessionCommit(target, model, agent.id)
|
||||
return client.api.session.switchModel({ sessionID: target, model }).catch((error) => {
|
||||
cancelCommit()
|
||||
throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error })
|
||||
})
|
||||
}
|
||||
history.append(entry)
|
||||
const dispatch = (send: () => Promise<unknown>) => {
|
||||
const setup = newSession
|
||||
|
|
@ -1276,8 +1279,12 @@ export function Prompt(props: PromptProps) {
|
|||
dispatch(() => client.api.session.shell({ sessionID: target, command: inputText }))
|
||||
setStore("mode", "normal")
|
||||
} else if (slashHead && isCommand) {
|
||||
const send = () =>
|
||||
client.api.session.command({
|
||||
const send = async () => {
|
||||
await prepareAgent()
|
||||
// Commands inherit the composer selection; command-specific overrides
|
||||
// remain server-owned and run after this preparation.
|
||||
await commitModel()
|
||||
return client.api.session.command({
|
||||
sessionID: target,
|
||||
command: slashHead.name,
|
||||
text: slashHead.arguments,
|
||||
|
|
@ -1286,6 +1293,7 @@ export function Prompt(props: PromptProps) {
|
|||
skills: entry.skills?.length ? entry.skills : undefined,
|
||||
delivery,
|
||||
})
|
||||
}
|
||||
const setup = newSession
|
||||
void (setup ? setup.gate.then(send) : send()).catch((error) => {
|
||||
if (setup) return setup.recover(error)
|
||||
|
|
@ -1298,19 +1306,12 @@ export function Prompt(props: PromptProps) {
|
|||
} else {
|
||||
move.startSubmit()
|
||||
try {
|
||||
if (!session) {
|
||||
await data.session.sync(target)
|
||||
session = data.session.get(target)
|
||||
}
|
||||
if (session?.agent !== agent.id) {
|
||||
await client.api.session.switchAgent({ sessionID: target, agent: agent.id })
|
||||
}
|
||||
await prepareAgent()
|
||||
} catch (error) {
|
||||
toast.show({ title: "Failed to prepare session", message: errorMessage(error), variant: "error" })
|
||||
restoreEntry()
|
||||
return true
|
||||
}
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
if (session?.revert) {
|
||||
const error = await client.api.session.revert.commit({ sessionID: target }).then(
|
||||
() => undefined,
|
||||
|
|
@ -1359,16 +1360,10 @@ export function Prompt(props: PromptProps) {
|
|||
skills: entry.skills?.length ? entry.skills : undefined,
|
||||
delivery,
|
||||
gate: newSession?.gate,
|
||||
prepare: () => {
|
||||
// Commit the captured selection after earlier admissions, including
|
||||
// compaction setup. Cached state may still precede their SSE echoes;
|
||||
// the server makes an unchanged selection a no-op.
|
||||
const cancelCommit = local.model.trackSessionCommit(target, model)
|
||||
return client.api.session.switchModel({ sessionID: target, model }).catch((error) => {
|
||||
cancelCommit()
|
||||
throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error })
|
||||
})
|
||||
},
|
||||
// Commit the captured selection after earlier admissions, including
|
||||
// compaction setup. Cached state may still precede their SSE echoes;
|
||||
// the server makes an unchanged selection a no-op.
|
||||
prepare: commitModel,
|
||||
})
|
||||
.catch((error) => {
|
||||
if (newSession) return newSession.recover(error)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createData } from "@opencode-ai/client/solid"
|
||||
import type { LocationRef } from "@opencode-ai/client"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
|
@ -21,6 +22,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
const [generatingTitles, setGeneratingTitles] = createStore<Record<string, boolean | undefined>>({})
|
||||
return {
|
||||
...data,
|
||||
location: {
|
||||
...data.location,
|
||||
async sync(ref?: LocationRef) {
|
||||
await data.location.syncInfo(ref)
|
||||
await Promise.all([data.location.sync(ref), data.location.config.sync(ref)])
|
||||
},
|
||||
},
|
||||
session: {
|
||||
...data.session,
|
||||
title: {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useEvent } from "./event"
|
|||
import path from "path"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import { useArgs } from "./args"
|
||||
import { useClient } from "./client"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import {
|
||||
|
|
@ -23,14 +22,7 @@ import { useRoute } from "./route"
|
|||
import { useData } from "./data"
|
||||
import { usePermission } from "./permission"
|
||||
import { useLocation } from "./location"
|
||||
|
||||
export function parseModel(model: string) {
|
||||
const [providerID, ...rest] = model.split("/")
|
||||
return {
|
||||
providerID: providerID,
|
||||
modelID: rest.join("/"),
|
||||
}
|
||||
}
|
||||
import { parse } from "../util/model"
|
||||
|
||||
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
|
||||
const seen = new Set<string>()
|
||||
|
|
@ -49,7 +41,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
name: "Local",
|
||||
init: () => {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const theme = useTheme()
|
||||
const { mode } = useThemes()
|
||||
|
|
@ -83,7 +74,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
)
|
||||
const [agentStore, setAgentStore] = createStore({
|
||||
current: undefined as string | undefined,
|
||||
draftBySession: {} as Record<string, { agent?: string } | undefined>,
|
||||
})
|
||||
onCleanup(event.on("session.deleted", (evt) => setAgentStore("draftBySession", evt.data.sessionID, undefined)))
|
||||
onCleanup(
|
||||
event.on("session.agent.selected", (evt) => {
|
||||
// Keep an entry after acknowledgment: CLI defaults must not override a user's later choice.
|
||||
if (agentStore.draftBySession[evt.data.sessionID]?.agent === evt.data.agent)
|
||||
setAgentStore("draftBySession", evt.data.sessionID, { agent: undefined })
|
||||
}),
|
||||
)
|
||||
const colors = createMemo(() => {
|
||||
const step = mode() === "light" ? 800 : 200
|
||||
return dedupeWith(
|
||||
|
|
@ -96,7 +96,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
return agents()
|
||||
},
|
||||
current() {
|
||||
return agents().find((agent) => agent.id === agentStore.current) ?? agents().at(0)
|
||||
const draft = route.data.type === "session" ? agentStore.draftBySession[route.data.sessionID] : undefined
|
||||
const selected =
|
||||
route.data.type === "session"
|
||||
? (draft?.agent ??
|
||||
(draft ? undefined : args.agent) ??
|
||||
data.session.get(route.data.sessionID)?.agent ??
|
||||
agentStore.current)
|
||||
: agentStore.current
|
||||
return agents().find((agent) => agent.id === selected) ?? agents().at(0)
|
||||
},
|
||||
set(id: string) {
|
||||
if (!agents().some((agent) => agent.id === id))
|
||||
|
|
@ -105,7 +113,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
message: `Agent not found: ${id}`,
|
||||
duration: 3000,
|
||||
})
|
||||
setAgentStore("current", id)
|
||||
batch(() => {
|
||||
const changed = this.current()?.id !== id
|
||||
if (changed) model.remember()
|
||||
setAgentStore("current", id)
|
||||
if (route.data.type === "session") setAgentStore("draftBySession", route.data.sessionID, { agent: id })
|
||||
// Retain both selections while agent and model commits arrive separately.
|
||||
const selected = changed && route.data.type === "session" ? model.current() : undefined
|
||||
if (selected) model.set(selected)
|
||||
})
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
batch(() => {
|
||||
|
|
@ -115,7 +131,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
if (next < 0) next = agents().length - 1
|
||||
if (next >= agents().length) next = 0
|
||||
const value = agents()[next]
|
||||
setAgentStore("current", value.id)
|
||||
this.set(value.id)
|
||||
})
|
||||
},
|
||||
color(id: string) {
|
||||
|
|
@ -141,14 +157,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
})
|
||||
const [selectionState, setSelectionState] = createStore<{
|
||||
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
|
||||
draftBySession: Record<string, ModelSelection | undefined>
|
||||
selectionBySessionAgent: Record<string, Record<string, ModelSelection | undefined> | undefined>
|
||||
}>({
|
||||
newSessionModelByLocationAgent: {},
|
||||
draftBySession: {},
|
||||
selectionBySessionAgent: {},
|
||||
})
|
||||
|
||||
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
|
||||
const pendingSelectionCommits = new Map<string, string>()
|
||||
const pendingSelectionCommits = new Map<string, { agentID: string; selection: string }>()
|
||||
const selectionKey = (value: ModelSelection) =>
|
||||
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
|
||||
const saveState = {
|
||||
|
|
@ -183,9 +199,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
if (saveState.pending) savePreferences()
|
||||
})
|
||||
|
||||
const configuredModel = createMemo(() => {
|
||||
const entry = data.location.config
|
||||
.list(location.ref)
|
||||
?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
|
||||
const configured = entry?.type === "document" ? entry.info.model : undefined
|
||||
if (!configured) return
|
||||
return typeof configured === "string"
|
||||
? { ...parse(configured), variant: undefined }
|
||||
: { providerID: configured.providerID, modelID: configured.model, variant: configured.variant }
|
||||
})
|
||||
|
||||
const fallbackModel = createMemo(() => {
|
||||
if (args.model) {
|
||||
const { providerID, modelID } = parseModel(args.model)
|
||||
const { providerID, modelID } = parse(args.model)
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
return {
|
||||
providerID,
|
||||
|
|
@ -194,6 +221,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
}
|
||||
}
|
||||
|
||||
const configured = configuredModel()
|
||||
if (configured && isModelValid(configured)) return configured
|
||||
|
||||
for (const item of preferences.recent) {
|
||||
if (isModelValid(item)) {
|
||||
return item
|
||||
|
|
@ -221,7 +251,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
|
||||
const model = newSessionModel()
|
||||
if (!model) return
|
||||
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
|
||||
return preferredSelection(model)
|
||||
})
|
||||
|
||||
const currentModel = createMemo(() => {
|
||||
|
|
@ -235,6 +265,23 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
|
||||
}
|
||||
|
||||
function preferredSelection(model: ModelPreferenceModel): ModelSelection {
|
||||
const configured = agent.current()?.model
|
||||
const fallback = configuredModel()
|
||||
const preferred = preferences.variant[modelPreferenceKey(model)]
|
||||
const variant = normalizeModelVariant(
|
||||
preferred ??
|
||||
(configured?.providerID === model.providerID && configured.id === model.modelID
|
||||
? configured.variant
|
||||
: undefined) ??
|
||||
(fallback?.providerID === model.providerID && fallback.modelID === model.modelID
|
||||
? fallback.variant
|
||||
: undefined),
|
||||
)
|
||||
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||
return { ...model, variant: info?.variants.some((item) => item.id === variant) ? variant : undefined }
|
||||
}
|
||||
|
||||
function durableSelection(sessionID: string): ModelSelection | undefined {
|
||||
const model = data.session.get(sessionID)?.model
|
||||
if (!model) return
|
||||
|
|
@ -246,15 +293,44 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
}
|
||||
|
||||
function sessionSelection(sessionID: string) {
|
||||
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
|
||||
const current = agent.current()
|
||||
if (!current) return
|
||||
const session = data.session.get(sessionID)
|
||||
const selected = [
|
||||
selectionState.selectionBySessionAgent[sessionID]?.[current.id],
|
||||
!session?.agent || session.agent === current.id ? durableSelection(sessionID) : undefined,
|
||||
].find((selection) => selection && isModelValid(selection))
|
||||
if (selected) {
|
||||
const info = models()?.find((item) => item.providerID === selected.providerID && item.id === selected.modelID)
|
||||
return {
|
||||
...selected,
|
||||
variant: info?.variants.some((variant) => variant.id === selected.variant) ? selected.variant : undefined,
|
||||
}
|
||||
}
|
||||
const model = newSessionModel()
|
||||
return model && preferredSelection(model)
|
||||
}
|
||||
|
||||
function setSessionSelection(sessionID: string, agentID: string, selection: ModelSelection | undefined) {
|
||||
setSelectionState("selectionBySessionAgent", sessionID, {
|
||||
...selectionState.selectionBySessionAgent[sessionID],
|
||||
[agentID]: selection,
|
||||
})
|
||||
}
|
||||
|
||||
function setSessionDraft(sessionID: string, selection: ModelSelection) {
|
||||
const current = agent.current()
|
||||
if (!current) return
|
||||
const durable = durableSelection(sessionID)
|
||||
setSelectionState(
|
||||
"draftBySession",
|
||||
const session = data.session.get(sessionID)
|
||||
setSessionSelection(
|
||||
sessionID,
|
||||
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
|
||||
current.id,
|
||||
(!session?.agent || session.agent === current.id) &&
|
||||
durable &&
|
||||
selectionKey(durable) === selectionKey(selection)
|
||||
? undefined
|
||||
: selection,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -262,14 +338,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
if (route.data.type === "session") {
|
||||
const sessionID = route.data.sessionID
|
||||
const current = sessionSelection(sessionID)
|
||||
const preferred = normalizeModelVariant(
|
||||
setSessionDraft(
|
||||
sessionID,
|
||||
current?.providerID === model.providerID && current.modelID === model.modelID
|
||||
? current.variant
|
||||
: preferences.variant[modelPreferenceKey(model)],
|
||||
? current
|
||||
: preferredSelection(model),
|
||||
)
|
||||
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
|
||||
setSessionDraft(sessionID, { ...model, variant })
|
||||
return true
|
||||
}
|
||||
const current = agent.current()
|
||||
|
|
@ -278,33 +352,43 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
return true
|
||||
}
|
||||
|
||||
onCleanup(
|
||||
event.on("session.model.selected", (evt) => {
|
||||
const expected = pendingSelectionCommits.get(evt.data.sessionID)
|
||||
if (!expected) return
|
||||
const committed = selectionKey({
|
||||
providerID: evt.data.model.providerID,
|
||||
modelID: evt.data.model.id,
|
||||
variant: evt.data.model.variant,
|
||||
})
|
||||
if (committed !== expected) return
|
||||
pendingSelectionCommits.delete(evt.data.sessionID)
|
||||
const draft = selectionState.draftBySession[evt.data.sessionID]
|
||||
if (draft && selectionKey(draft) === committed)
|
||||
setSelectionState("draftBySession", evt.data.sessionID, undefined)
|
||||
}),
|
||||
)
|
||||
function reconcileSessionSelection(sessionID: string) {
|
||||
const expected = pendingSelectionCommits.get(sessionID)
|
||||
const durable = durableSelection(sessionID)
|
||||
if (!expected || !durable || data.session.get(sessionID)?.agent !== expected.agentID) return
|
||||
if (selectionKey(durable) !== expected.selection) return
|
||||
pendingSelectionCommits.delete(sessionID)
|
||||
// Inactive agents keep their remembered choices after another agent commits.
|
||||
if (
|
||||
route.data.type !== "session" ||
|
||||
route.data.sessionID !== sessionID ||
|
||||
agent.current()?.id !== expected.agentID
|
||||
)
|
||||
return
|
||||
const draft = selectionState.selectionBySessionAgent[sessionID]?.[expected.agentID]
|
||||
if (draft && selectionKey(draft) === expected.selection)
|
||||
setSessionSelection(sessionID, expected.agentID, undefined)
|
||||
}
|
||||
|
||||
onCleanup(event.on("session.model.selected", (evt) => reconcileSessionSelection(evt.data.sessionID)))
|
||||
onCleanup(event.on("session.agent.selected", (evt) => reconcileSessionSelection(evt.data.sessionID)))
|
||||
|
||||
onCleanup(
|
||||
event.on("session.deleted", (evt) => {
|
||||
pendingSelectionCommits.delete(evt.data.sessionID)
|
||||
setSelectionState("draftBySession", evt.data.sessionID, undefined)
|
||||
setSelectionState("selectionBySessionAgent", evt.data.sessionID, undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
current: currentModel,
|
||||
selection: currentSelection,
|
||||
remember() {
|
||||
const current = agent.current()
|
||||
const selection = currentSelection()
|
||||
if (route.data.type !== "session" || !current || !selection) return
|
||||
setSessionSelection(route.data.sessionID, current.id, { ...selection })
|
||||
},
|
||||
available(model = currentModel()) {
|
||||
return model ? isModelValid(model) : false
|
||||
},
|
||||
|
|
@ -315,9 +399,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
id: string
|
||||
variant?: string
|
||||
},
|
||||
agentID: string,
|
||||
) {
|
||||
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
|
||||
const committed = {
|
||||
agentID,
|
||||
selection: selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant }),
|
||||
}
|
||||
pendingSelectionCommits.set(sessionID, committed)
|
||||
// An unchanged model emits no event; the agent may be the only durable change.
|
||||
reconcileSessionSelection(sessionID)
|
||||
return () => {
|
||||
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
|
||||
}
|
||||
|
|
@ -354,7 +444,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
cycle(direction: 1 | -1) {
|
||||
const current = currentSelection()
|
||||
if (!current) return
|
||||
const recent = recentModels(current, preferences.recent).filter(isModelValid)
|
||||
const recent = preferences.recent.filter(isModelValid)
|
||||
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
||||
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
|
||||
if (next < 0) next = recent.length - 1
|
||||
|
|
@ -422,9 +512,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
return currentSelection()?.variant
|
||||
},
|
||||
current() {
|
||||
const v = this.selected()
|
||||
if (v && this.list().includes(v)) return v
|
||||
return undefined
|
||||
return this.selected()
|
||||
},
|
||||
list() {
|
||||
const m = currentSelection()
|
||||
|
|
@ -438,7 +526,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||
if (route.data.type === "session") {
|
||||
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
|
||||
}
|
||||
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||
setPreferences("variant", modelPreferenceKey(m), value ?? "default")
|
||||
savePreferences()
|
||||
},
|
||||
cycle() {
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ function variants(value: unknown) {
|
|||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
if (key.length === 0 || typeof item !== "string" || item.length === 0) return []
|
||||
const variant = normalizeModelVariant(item)
|
||||
return variant === undefined ? [] : ([[key, variant]] as const)
|
||||
// Preserve explicit "default" so it can override an agent's configured variant.
|
||||
return [[key, item]] as const
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -108,17 +108,12 @@ export function createModelPreferenceRepository(filePath: string) {
|
|||
return update(() => value)
|
||||
},
|
||||
async resolveVariant(model: ModelPreferenceModel) {
|
||||
return (await load()).variant[modelPreferenceKey(model)]
|
||||
return normalizeModelVariant((await load()).variant[modelPreferenceKey(model)])
|
||||
},
|
||||
saveVariant(model: ModelPreferenceModel, value: string | undefined) {
|
||||
const key = modelPreferenceKey(model)
|
||||
const next = normalizeModelVariant(value)
|
||||
return update((current) => {
|
||||
const variant = { ...current.variant }
|
||||
if (next === undefined) delete variant[key]
|
||||
if (next !== undefined) variant[key] = next
|
||||
return { variant }
|
||||
})
|
||||
const next = normalizeModelVariant(value) ?? "default"
|
||||
return update((current) => ({ variant: { ...current.variant, [key]: next } }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ import { Effect, FileSystem } from "effect"
|
|||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import path from "node:path"
|
||||
import { createEventStream, createFetch, directory, json, type FetchHandler } from "./fixture/tui-client"
|
||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
import type { TuiInput } from "../src/app"
|
||||
import type { Config } from "../src/config"
|
||||
import { createAppFixture } from "./fixture/app"
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
|
||||
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
|
||||
|
|
@ -1530,54 +1529,3 @@ test("server plugin failures share one notice and use source names before an ID
|
|||
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
|
||||
expect(setup.captureCharFrame()).toContain("Open plugins")
|
||||
})
|
||||
|
||||
async function createAppFixture(
|
||||
input: {
|
||||
width?: number
|
||||
height?: number
|
||||
state?: string
|
||||
config?: Config.Info
|
||||
args?: TuiInput["args"]
|
||||
fetch?: FetchHandler
|
||||
} = {},
|
||||
) {
|
||||
const { run } = await import("../src/app")
|
||||
const setup = await createTestRenderer({
|
||||
width: input.width ?? 100,
|
||||
height: input.height ?? 30,
|
||||
useThread: false,
|
||||
kittyKeyboard: true,
|
||||
})
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(input.fetch, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: input.args ?? {},
|
||||
log: () => {},
|
||||
}).pipe(
|
||||
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
|
||||
Effect.provide(FileSystem.layerNoop({})),
|
||||
),
|
||||
)
|
||||
return {
|
||||
...setup,
|
||||
events,
|
||||
ready: ready.promise,
|
||||
async [Symbol.asyncDispose]() {
|
||||
try {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
108
packages/tui/test/command-selection.test.tsx
Normal file
108
packages/tui/test/command-selection.test.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { InputRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import { directory, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
import { createAppFixture } from "./fixture/app"
|
||||
|
||||
test("custom commands commit the captured agent, model and variant before execution", async () => {
|
||||
await using state = await tmpdir()
|
||||
const agent = Promise.withResolvers<Response>()
|
||||
const model = Promise.withResolvers<Response>()
|
||||
const mutations: { type: string; body: unknown }[] = []
|
||||
const location = { directory, project: { id: "project", directory, canonical: directory } }
|
||||
const session = {
|
||||
id: `ses_${crypto.randomUUID()}`,
|
||||
projectID: "project",
|
||||
title: "Command selection fixture",
|
||||
agent: "build",
|
||||
model: { providerID: "demo", id: "first" },
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
await using setup = await createAppFixture({
|
||||
state: state.path,
|
||||
config: {
|
||||
animations: false,
|
||||
keybinds: { "agent.cycle": "f6", "variant.cycle": "f7", "model.list": "f8" },
|
||||
},
|
||||
args: { sessionID: session.id },
|
||||
fetch: async (url, request) => {
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({
|
||||
location,
|
||||
data: ["build", "plan"].map((id) => ({ id, mode: "primary", hidden: false, permissions: [] })),
|
||||
})
|
||||
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
|
||||
if (url.pathname === "/api/model")
|
||||
return json({
|
||||
location,
|
||||
data: ["first", "second"].map((id) => ({
|
||||
id,
|
||||
providerID: "demo",
|
||||
name: `${id} model`,
|
||||
variants: [{ id: "low" }, { id: "high" }],
|
||||
cost: [],
|
||||
time: { released: 0 },
|
||||
})),
|
||||
})
|
||||
if (url.pathname === "/api/command")
|
||||
return json({ location, data: [{ name: "review", description: "Review the input" }] })
|
||||
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
||||
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
|
||||
return json({ data: [], cursor: {} })
|
||||
const type = url.pathname.match(/^\/api\/session\/[^/]+\/(agent|model|command)$/)?.[1]
|
||||
if (!type) return
|
||||
mutations.push({ type, body: await request.json() })
|
||||
return type === "agent" ? agent.promise : type === "model" ? model.promise : new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
try {
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("Build · first model"))
|
||||
setup.mockInput.pressKey("F6")
|
||||
await setup.waitForFrame((frame) => frame.includes("Plan ·"))
|
||||
setup.mockInput.pressKey("F8")
|
||||
await setup.waitForFrame(
|
||||
(frame) => frame.includes("Select model") && setup.renderer.currentFocusedRenderable instanceof InputRenderable,
|
||||
)
|
||||
await setup.mockInput.typeText("second")
|
||||
await setup.renderOnce()
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame((frame) => frame.includes("Select variant") && frame.includes("low"))
|
||||
await setup.mockInput.typeText("low")
|
||||
await setup.renderOnce()
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame(
|
||||
(frame) =>
|
||||
frame.includes("Plan · second model Demo · low") &&
|
||||
setup.renderer.currentFocusedRenderable instanceof TextareaRenderable,
|
||||
)
|
||||
await setup.mockInput.typeText("/review selected input")
|
||||
setup.mockInput.pressEscape()
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitFor(() => mutations.length > 0)
|
||||
expect(mutations).toEqual([{ type: "agent", body: { agent: "plan" } }])
|
||||
|
||||
// A later local edit must not change the in-flight command's selection.
|
||||
setup.mockInput.pressKey("F7")
|
||||
await setup.waitForFrame((frame) => frame.includes("high"))
|
||||
agent.resolve(new Response(null, { status: 204 }))
|
||||
await setup.waitFor(() => mutations.length === 2)
|
||||
expect(mutations[1]).toEqual({
|
||||
type: "model",
|
||||
body: { model: { providerID: "demo", id: "second", variant: "low" } },
|
||||
})
|
||||
model.resolve(new Response(null, { status: 204 }))
|
||||
await setup.waitFor(() => mutations.length === 3)
|
||||
expect(mutations[2]).toEqual({
|
||||
type: "command",
|
||||
body: { command: "review", text: "selected input", files: [], agents: [], delivery: "steer" },
|
||||
})
|
||||
} finally {
|
||||
agent.resolve(new Response(null, { status: 204 }))
|
||||
model.resolve(new Response(null, { status: 204 }))
|
||||
}
|
||||
})
|
||||
18
packages/tui/test/component/dialog-variant.test.tsx
Normal file
18
packages/tui/test/component/dialog-variant.test.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { DialogVariant } from "../../src/component/dialog-variant"
|
||||
import { agent, model, renderLocal } from "../fixture/local"
|
||||
|
||||
test("variant picker can explicitly reset an agent variant", async () => {
|
||||
await using setup = await renderLocal({
|
||||
models: [model("first", ["low", "high"])],
|
||||
agents: [agent("build", { providerID: "provider", id: "first", variant: "high" })],
|
||||
})
|
||||
expect(setup.local.model.variant.current()).toBe("high")
|
||||
setup.dialog.replace(() => <DialogVariant />)
|
||||
await setup.waitForFrame((frame) => frame.includes("Select variant") && frame.includes("Default"))
|
||||
await setup.mockInput.typeText("Default")
|
||||
await setup.renderOnce()
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitFor(() => setup.dialog.stack.length === 0)
|
||||
expect(setup.local.model.variant.current()).toBeUndefined()
|
||||
})
|
||||
189
packages/tui/test/context/local-selection.test.tsx
Normal file
189
packages/tui/test/context/local-selection.test.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { agent, model, renderLocal, session } from "../fixture/local"
|
||||
import { json } from "../fixture/tui-client"
|
||||
|
||||
test("cycles all recent models in a stable order in both directions", async () => {
|
||||
await using setup = await renderLocal({
|
||||
models: [model("first"), model("second"), model("third")],
|
||||
preferences: { recent: ["first", "second", "third"].map((modelID) => ({ providerID: "provider", modelID })) },
|
||||
})
|
||||
expect(setup.local.model.current()?.modelID).toBe("first")
|
||||
for (const id of ["second", "third", "first"]) {
|
||||
setup.local.model.cycle(1)
|
||||
expect(setup.local.model.current()?.modelID).toBe(id)
|
||||
}
|
||||
for (const id of ["third", "second", "first"]) {
|
||||
setup.local.model.cycle(-1)
|
||||
expect(setup.local.model.current()?.modelID).toBe(id)
|
||||
}
|
||||
})
|
||||
|
||||
test("uses the last configured model and variant ahead of recents", async () => {
|
||||
await using setup = await renderLocal({
|
||||
models: [model("first"), model("second", ["low", "high"]), model("third")],
|
||||
preferences: { recent: [{ providerID: "provider", modelID: "third" }] },
|
||||
fetch: (url) => {
|
||||
if (url.pathname === "/api/config")
|
||||
return json([
|
||||
{ type: "document", info: { model: "provider/first" } },
|
||||
{ type: "document", info: { model: { providerID: "provider", model: "second", variant: "high" } } },
|
||||
{ type: "document", info: {} },
|
||||
])
|
||||
},
|
||||
})
|
||||
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "second", variant: "high" })
|
||||
})
|
||||
|
||||
test("switching agents restores their model and variant within the session", async () => {
|
||||
await using setup = await renderLocal({
|
||||
models: [model("first", ["low", "high"]), model("second", ["low", "high"]), model("third", ["low", "high"])],
|
||||
agents: [
|
||||
agent("build", { providerID: "provider", id: "first", variant: "high" }),
|
||||
agent("plan", { providerID: "provider", id: "second", variant: "low" }),
|
||||
],
|
||||
sessions: [session("ses_first", { providerID: "provider", id: "first", variant: "low" })],
|
||||
})
|
||||
await setup.data.session.sync("ses_first")
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_first" })
|
||||
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
|
||||
setup.local.agent.move(1)
|
||||
expect(setup.local.agent.current()?.id).toBe("plan")
|
||||
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "second", variant: "low" })
|
||||
setup.local.model.set({ providerID: "provider", modelID: "third" })
|
||||
setup.local.model.variant.set("high")
|
||||
setup.local.agent.move(-1)
|
||||
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
|
||||
setup.local.agent.set("plan")
|
||||
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "third", variant: "high" })
|
||||
})
|
||||
|
||||
test("agent and model drafts are isolated across sessions and survive navigation", async () => {
|
||||
await using setup = await renderLocal({
|
||||
models: [model("first", ["low", "high"]), model("second", ["low", "high"])],
|
||||
agents: [agent("build"), agent("plan", { providerID: "provider", id: "second" })],
|
||||
sessions: [
|
||||
session("ses_first", { providerID: "provider", id: "first", variant: "low" }),
|
||||
session("ses_second", { providerID: "provider", id: "second", variant: "high" }, "plan"),
|
||||
],
|
||||
})
|
||||
await Promise.all([setup.data.session.sync("ses_first"), setup.data.session.sync("ses_second")])
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_first" })
|
||||
setup.local.agent.set("plan")
|
||||
setup.local.model.variant.set("low")
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_second" })
|
||||
expect(setup.local.agent.current()?.id).toBe("plan")
|
||||
expect(setup.local.model.variant.current()).toBe("high")
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_first" })
|
||||
expect(setup.local.agent.current()?.id).toBe("plan")
|
||||
expect(setup.local.model.variant.current()).toBe("low")
|
||||
setup.local.agent.set("build")
|
||||
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
|
||||
})
|
||||
|
||||
test("falls back from an unavailable session model without changing durable state", async () => {
|
||||
const selected = { providerID: "provider", id: "missing", variant: "high" }
|
||||
await using setup = await renderLocal({
|
||||
models: [model("first", ["low", "high"]), model("second")],
|
||||
agents: [agent("build", { providerID: "provider", id: "first", variant: "low" })],
|
||||
sessions: [session("ses_first", selected)],
|
||||
})
|
||||
await setup.data.session.sync("ses_first")
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_first" })
|
||||
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
|
||||
expect(setup.local.model.available()).toBe(true)
|
||||
expect(setup.data.session.get("ses_first")?.model).toEqual(selected)
|
||||
})
|
||||
|
||||
test("a manual agent switch supersedes the CLI agent after its commit", async () => {
|
||||
await using setup = await renderLocal({
|
||||
args: { agent: "build" },
|
||||
agents: [agent("build"), agent("plan")],
|
||||
sessions: [session("ses_first", { providerID: "provider", id: "first" })],
|
||||
fetch: selectionMessage,
|
||||
})
|
||||
await setup.data.session.sync("ses_first")
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_first" })
|
||||
setup.local.agent.set("plan")
|
||||
await publishSelection(setup, "plan", "first")
|
||||
expect(setup.local.agent.current()?.id).toBe("plan")
|
||||
setup.route.navigate({ type: "home" })
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_first" })
|
||||
expect(setup.local.agent.current()?.id).toBe("plan")
|
||||
})
|
||||
|
||||
test("a late inactive-agent acknowledgment preserves its choice after the active agent commits", async () => {
|
||||
await using setup = await renderLocal({
|
||||
models: [model("first"), model("second"), model("third")],
|
||||
agents: [agent("build"), agent("plan", { providerID: "provider", id: "second" })],
|
||||
sessions: [session("ses_first", { providerID: "provider", id: "first" })],
|
||||
fetch: selectionMessage,
|
||||
})
|
||||
await setup.data.session.sync("ses_first")
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_first" })
|
||||
setup.local.agent.set("plan")
|
||||
setup.local.model.set({ providerID: "provider", modelID: "third" })
|
||||
setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "third" }, "plan")
|
||||
setup.local.agent.set("build")
|
||||
await publishSelection(setup, "plan", "third")
|
||||
setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "first" }, "build")
|
||||
await publishSelection(setup, "build", "first")
|
||||
setup.local.agent.set("plan")
|
||||
expect(setup.local.model.current()?.modelID).toBe("third")
|
||||
})
|
||||
|
||||
test("same-model agent switches clear drafts without a model acknowledgment", async () => {
|
||||
await using setup = await renderLocal({
|
||||
models: [model("first"), model("second")],
|
||||
agents: [agent("build"), agent("plan")],
|
||||
sessions: [session("ses_first", { providerID: "provider", id: "first" })],
|
||||
fetch: selectionMessage,
|
||||
})
|
||||
await setup.data.session.sync("ses_first")
|
||||
setup.route.navigate({ type: "session", sessionID: "ses_first" })
|
||||
setup.local.agent.set("plan")
|
||||
setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "first" }, "plan")
|
||||
await publishSelection(setup, "plan", "first", false)
|
||||
await publishSelection(setup, "plan", "second")
|
||||
expect(setup.local.model.current()?.modelID).toBe("second")
|
||||
})
|
||||
|
||||
async function publishSelection(
|
||||
setup: Awaited<ReturnType<typeof renderLocal>>,
|
||||
agent: string,
|
||||
modelID: string,
|
||||
changed = true,
|
||||
) {
|
||||
setup.events.emit({
|
||||
id: `evt_${crypto.randomUUID()}`,
|
||||
type: "session.agent.selected",
|
||||
created: 1,
|
||||
durable: { aggregateID: "ses_first", seq: 1, version: 1 },
|
||||
data: { sessionID: "ses_first", agent },
|
||||
})
|
||||
if (changed)
|
||||
setup.events.emit({
|
||||
id: `evt_${crypto.randomUUID()}_${modelID}`,
|
||||
type: "session.model.selected",
|
||||
created: 2,
|
||||
durable: { aggregateID: "ses_first", seq: 2, version: 1 },
|
||||
data: { sessionID: "ses_first", model: { providerID: "provider", id: modelID } },
|
||||
})
|
||||
await setup.waitFor(async () => {
|
||||
await Bun.sleep(10)
|
||||
const session = setup.data.session.get("ses_first")
|
||||
return session?.agent === agent && session.model?.id === modelID
|
||||
})
|
||||
}
|
||||
|
||||
function selectionMessage(url: URL) {
|
||||
if (!url.pathname.includes("/message/")) return
|
||||
const id = url.pathname.split("/").at(-1)!
|
||||
return json({
|
||||
data: {
|
||||
id,
|
||||
type: "model-switched",
|
||||
model: { providerID: "provider", id: id.split("_").at(-1) },
|
||||
time: { created: 2 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,12 +1,5 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { parseModel, recentModels } from "../../src/context/local"
|
||||
|
||||
test("parses model IDs containing slashes", () => {
|
||||
expect(parseModel("provider/family/model")).toEqual({
|
||||
providerID: "provider",
|
||||
modelID: "family/model",
|
||||
})
|
||||
})
|
||||
import { recentModels } from "../../src/context/local"
|
||||
|
||||
test("moves a model to the front, deduplicates, and limits recents", () => {
|
||||
const recent = Array.from({ length: 12 }, (_, index) => ({
|
||||
|
|
|
|||
58
packages/tui/test/fixture/app.ts
Normal file
58
packages/tui/test/fixture/app.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import type { TuiInput } from "../../src/app"
|
||||
import type { Config } from "../../src/config"
|
||||
import { createEventStream, createFetch, type FetchHandler } from "./tui-client"
|
||||
|
||||
export async function createAppFixture(
|
||||
input: {
|
||||
width?: number
|
||||
height?: number
|
||||
state?: string
|
||||
config?: Config.Info
|
||||
args?: TuiInput["args"]
|
||||
fetch?: FetchHandler
|
||||
} = {},
|
||||
) {
|
||||
const { run } = await import("../../src/app")
|
||||
const setup = await createTestRenderer({
|
||||
width: input.width ?? 100,
|
||||
height: input.height ?? 30,
|
||||
useThread: false,
|
||||
kittyKeyboard: true,
|
||||
})
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(input.fetch, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
|
||||
packages: { prepare: async () => ({ directory: "" }) },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: input.args ?? {},
|
||||
log: () => {},
|
||||
}).pipe(
|
||||
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
|
||||
Effect.provide(FileSystem.layerNoop({})),
|
||||
),
|
||||
)
|
||||
return {
|
||||
...setup,
|
||||
events,
|
||||
ready: ready.promise,
|
||||
async [Symbol.asyncDispose]() {
|
||||
try {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
149
packages/tui/test/fixture/local.tsx
Normal file
149
packages/tui/test/fixture/local.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { testRender } from "@opentui/solid"
|
||||
import type { AgentInfo, ModelInfo, SessionInfo } from "@opencode-ai/client"
|
||||
import path from "node:path"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import { ArgsProvider, type Args } from "../../src/context/args"
|
||||
import { ClientProvider } from "../../src/context/client"
|
||||
import { DataProvider, useData } from "../../src/context/data"
|
||||
import { LocalProvider, useLocal } from "../../src/context/local"
|
||||
import { Keymap } from "../../src/context/keymap"
|
||||
import { LocationProvider, useLocation } from "../../src/context/location"
|
||||
import { PermissionProvider } from "../../src/context/permission"
|
||||
import { RouteProvider, useRoute } from "../../src/context/route"
|
||||
import { ThemeProvider } from "../../src/context/theme"
|
||||
import { ToastProvider } from "../../src/ui/toast"
|
||||
import { DialogProvider, useDialog } from "../../src/ui/dialog"
|
||||
import type { ModelPreference } from "../../src/model-preference"
|
||||
import { tmpdir } from "./fixture"
|
||||
import { createApi, createEventStream, createFetch, directory, json, type FetchHandler } from "./tui-client"
|
||||
import { TestTuiContexts } from "./tui-environment"
|
||||
import { createTuiResolvedConfig } from "./tui-runtime"
|
||||
|
||||
export async function renderLocal(
|
||||
input: {
|
||||
models?: ModelInfo[]
|
||||
agents?: AgentInfo[]
|
||||
sessions?: SessionInfo[]
|
||||
preferences?: Partial<ModelPreference>
|
||||
args?: Args
|
||||
fetch?: FetchHandler
|
||||
} = {},
|
||||
) {
|
||||
const temporary = await tmpdir()
|
||||
await Bun.write(path.join(temporary.path, "model.json"), JSON.stringify(input.preferences ?? {}))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(async (url, request) => {
|
||||
const response = await input.fetch?.(url, request)
|
||||
if (response) return response
|
||||
const location = { directory: url.searchParams.get("location[directory]") ?? directory }
|
||||
if (url.pathname === "/api/agent") return json({ location, data: input.agents ?? [agent("build")] })
|
||||
if (url.pathname === "/api/model") return json({ location, data: input.models ?? [model("first")] })
|
||||
const session = input.sessions?.find((session) => url.pathname === `/api/session/${session.id}`)
|
||||
if (session) return json({ data: session })
|
||||
}, events)
|
||||
let local!: ReturnType<typeof useLocal>
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
let data!: ReturnType<typeof useData>
|
||||
let location!: ReturnType<typeof useLocation>
|
||||
let dialog!: ReturnType<typeof useDialog>
|
||||
|
||||
function Probe() {
|
||||
local = useLocal()
|
||||
route = useRoute()
|
||||
data = useData()
|
||||
location = useLocation()
|
||||
dialog = useDialog()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const setup = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts paths={{ state: temporary.path }}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<ToastProvider>
|
||||
<RouteProvider initialRoute={{ type: "home" }}>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider directory={directory}>
|
||||
<LocationProvider>
|
||||
<PermissionProvider>
|
||||
<LocalProvider>
|
||||
<DialogProvider>
|
||||
<Probe />
|
||||
</DialogProvider>
|
||||
</LocalProvider>
|
||||
</PermissionProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 30, kittyKeyboard: true },
|
||||
)
|
||||
await setup.waitFor(() => local !== undefined && local.model.ready)
|
||||
await data.location.sync()
|
||||
return {
|
||||
...setup,
|
||||
local,
|
||||
route,
|
||||
data,
|
||||
location,
|
||||
dialog,
|
||||
events,
|
||||
state: temporary.path,
|
||||
async [Symbol.asyncDispose]() {
|
||||
setup.renderer.destroy()
|
||||
await temporary[Symbol.asyncDispose]()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function model(id: string, variants: string[] = []): ModelInfo {
|
||||
return {
|
||||
id,
|
||||
modelID: id,
|
||||
providerID: "provider",
|
||||
name: id,
|
||||
status: "active",
|
||||
enabled: true,
|
||||
capabilities: { input: ["text"], output: ["text"], tools: true },
|
||||
cost: [],
|
||||
limit: { context: 10000, output: 1000 },
|
||||
time: { released: 0 },
|
||||
variants: variants.map((id) => ({ id })),
|
||||
}
|
||||
}
|
||||
|
||||
export function agent(id: string, selected?: AgentInfo["model"]): AgentInfo {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
model: selected,
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
permissions: [],
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
}
|
||||
}
|
||||
|
||||
export function session(id: string, selected?: SessionInfo["model"], agent = "build"): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
agent,
|
||||
model: selected,
|
||||
title: id,
|
||||
location: { directory },
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
}
|
||||
|
|
@ -134,6 +134,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||
data: { resources: [], templates: [] },
|
||||
})
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/config") return json([])
|
||||
if (url.pathname === "/api/session/active") return json({ data: {} })
|
||||
if (request.method === "POST" && /^\/api\/session\/[^/]+\/model$/.test(url.pathname))
|
||||
return new Response(null, { status: 204 })
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ test("repairs known model preferences and preserves unrelated fields", () => {
|
|||
unrelated: { keep: true },
|
||||
recent: [{ providerID: "openai", modelID: "gpt-5" }],
|
||||
favorite: [],
|
||||
variant: { "openai/gpt-5": "high" },
|
||||
variant: { "openai/gpt-5": "high", default: "default" },
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -41,5 +41,8 @@ test("atomically serializes patches and variant updates", async () => {
|
|||
|
||||
await repository.saveVariant(openai, "default")
|
||||
expect(await repository.resolveVariant(openai)).toBeUndefined()
|
||||
expect((await Bun.file(file).json()).variant).toEqual({ "anthropic/claude/sonnet": "low" })
|
||||
expect((await Bun.file(file).json()).variant).toEqual({
|
||||
"openai/org/gpt-5": "default",
|
||||
"anthropic/claude/sonnet": "low",
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue