mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-18 21:53:12 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 729eafc2e7 |
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||
import { Context, Effect, FileSystem, Option } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -19,21 +19,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||
const preflight = UpdatePreflight.make()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||
const serviceStarts = yield* Queue.unbounded<{
|
||||
readonly reason: "missing" | "version-mismatch"
|
||||
readonly previousVersion?: string
|
||||
}>()
|
||||
yield* Queue.take(serviceStarts).pipe(
|
||||
Effect.flatMap((event) => Effect.logInfo("background service starting", event)),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: requestedServer,
|
||||
standalone: input.standalone,
|
||||
mismatch: "replace",
|
||||
onStart: (reason, previousVersion) => {
|
||||
Queue.offerUnsafe(serviceStarts, { reason, previousVersion })
|
||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||
process.stderr.write(
|
||||
reason === "version-mismatch"
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
export * as ServerProcess from "./server-process"
|
||||
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { Service, type DiscoverOptions } from "@opencode-ai/client/effect/service"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { Effect, Option, Redacted, Schedule } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
|
||||
@@ -121,7 +121,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
onListen: (address, shutdown) =>
|
||||
Effect.gen(function* () {
|
||||
if (!config.password) yield* ServiceConfig.password(password)
|
||||
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
|
||||
return yield* ServiceRegistration.register(
|
||||
address,
|
||||
password,
|
||||
instanceID,
|
||||
serviceOptions.file,
|
||||
shutdown,
|
||||
)
|
||||
}),
|
||||
},
|
||||
transform,
|
||||
@@ -158,59 +164,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
})
|
||||
|
||||
const infoJson = Schema.fromJsonString(Service.Info)
|
||||
const encodeInfo = Schema.encodeEffect(infoJson)
|
||||
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
|
||||
|
||||
const register = Effect.fnUntraced(function* (
|
||||
address: HttpServer.Address,
|
||||
password: string,
|
||||
id: string,
|
||||
file: string,
|
||||
shutdown: Effect.Effect<void>,
|
||||
) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const temp = file + "." + id + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||
const info = {
|
||||
id,
|
||||
version: OPENCODE_VERSION,
|
||||
url: HttpServer.formatAddress(address),
|
||||
pid: process.pid,
|
||||
password,
|
||||
}
|
||||
const encoded = yield* encodeInfo(info)
|
||||
const current = fs.readFileString(file).pipe(
|
||||
Effect.flatMap(decodeInfo),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
const owns = (found: Info | undefined) =>
|
||||
found?.id === info.id &&
|
||||
found.version === info.version &&
|
||||
found.url === info.url &&
|
||||
found.pid === info.pid &&
|
||||
found.password === info.password
|
||||
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
||||
yield* current.pipe(
|
||||
Effect.filterOrFail(owns),
|
||||
Effect.repeat(Schedule.spaced("5 seconds")),
|
||||
Effect.tapError(() =>
|
||||
Effect.logWarning("managed service registration lost; shutting down", {
|
||||
serviceID: id,
|
||||
servicePID: process.pid,
|
||||
registration: file,
|
||||
}),
|
||||
),
|
||||
Effect.ignore,
|
||||
Effect.andThen(shutdown),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return current.pipe(
|
||||
Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
|
||||
Effect.ignore,
|
||||
)
|
||||
})
|
||||
|
||||
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
|
||||
Effect.filterOrFail((value) => value !== undefined),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export * as ServiceRegistration from "./service-registration"
|
||||
|
||||
import { Service, type Info } from "@opencode-ai/client/effect/service"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Schedule, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
|
||||
const infoJson = Schema.fromJsonString(Service.Info)
|
||||
const encodeInfo = Schema.encodeEffect(infoJson)
|
||||
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
|
||||
|
||||
export const register = Effect.fnUntraced(function* (
|
||||
address: HttpServer.Address,
|
||||
password: string,
|
||||
id: string,
|
||||
file: string,
|
||||
shutdown: Effect.Effect<void>,
|
||||
) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const temp = file + "." + id + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||
const info = {
|
||||
id,
|
||||
version: OPENCODE_VERSION,
|
||||
url: HttpServer.formatAddress(address),
|
||||
pid: process.pid,
|
||||
password,
|
||||
}
|
||||
const encoded = yield* encodeInfo(info)
|
||||
const current = fs.readFileString(file).pipe(
|
||||
Effect.flatMap(decodeInfo),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
const owns = (found: Info | undefined) =>
|
||||
found?.id === info.id &&
|
||||
found.version === info.version &&
|
||||
found.url === info.url &&
|
||||
found.pid === info.pid &&
|
||||
found.password === info.password
|
||||
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
||||
yield* current.pipe(
|
||||
Effect.filterOrFail(owns),
|
||||
Effect.repeat(Schedule.spaced("5 seconds")),
|
||||
Effect.ignore,
|
||||
Effect.andThen(shutdown),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return current.pipe(
|
||||
Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
|
||||
Effect.ignore,
|
||||
)
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
import { ServiceRegistration } from "../src/services/service-registration"
|
||||
|
||||
test("managed service ports are stable per installation channel", () => {
|
||||
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
|
||||
@@ -440,34 +441,37 @@ test("port contender recognizes an incumbent registered during the bind race", a
|
||||
}
|
||||
}, 45_000)
|
||||
|
||||
test("stale dead registration is replaced after binding the selected port", async () => {
|
||||
test("service registration replaces a stale owner with the bound address", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-"))
|
||||
const port = await availablePort()
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
|
||||
await fs.writeFile(
|
||||
registration,
|
||||
JSON.stringify({ id: "dead", version: "dead", url: `http://127.0.0.1:${port}`, pid: 2_147_483_647 }),
|
||||
JSON.stringify({ id: "dead", version: "dead", url: "http://127.0.0.1:4321", pid: 2_147_483_647 }),
|
||||
)
|
||||
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||
env: serviceEnv(root),
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
})
|
||||
try {
|
||||
const info = await waitForInfo(registration, (value) => value.id !== "dead")
|
||||
expect(new URL(info.url).port).toBe(String(port))
|
||||
expect(info.pid).toBe(owner.pid)
|
||||
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
await owner.exited
|
||||
const cleanup = await Effect.runPromise(
|
||||
ServiceRegistration.register(
|
||||
{ _tag: "TcpAddress", hostname: "127.0.0.1", port: 4321 },
|
||||
"secret",
|
||||
"owner",
|
||||
registration,
|
||||
Effect.never,
|
||||
).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
expect(await Bun.file(registration).json()).toEqual({
|
||||
id: "owner",
|
||||
version: OPENCODE_VERSION,
|
||||
url: "http://127.0.0.1:4321",
|
||||
pid: process.pid,
|
||||
password: "secret",
|
||||
})
|
||||
await Effect.runPromise(cleanup.pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
} finally {
|
||||
owner.kill("SIGTERM")
|
||||
await owner.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
test("a failed service stays registered and owns the selected port until stopped", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
|
||||
|
||||
@@ -1232,6 +1232,7 @@ export function Prompt(props: PromptProps) {
|
||||
} else if (slashHead && isCommand) {
|
||||
move.startSubmit()
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||
|
||||
void client.api.session
|
||||
.command({
|
||||
@@ -1246,6 +1247,7 @@ export function Prompt(props: PromptProps) {
|
||||
delivery,
|
||||
})
|
||||
.catch((error) => {
|
||||
cancelCommit()
|
||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||
})
|
||||
} else if (isSkill) {
|
||||
@@ -1269,7 +1271,11 @@ export function Prompt(props: PromptProps) {
|
||||
(session.model.variant ?? "default") !== (variant ?? "default")
|
||||
) {
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
await client.api.session.switchModel({ sessionID, model })
|
||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
|
||||
cancelCommit()
|
||||
throw error
|
||||
})
|
||||
}
|
||||
if (session?.revert) {
|
||||
const error = await client.api.session.revert.commit({ sessionID }).then(
|
||||
|
||||
@@ -45,39 +45,6 @@ export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenc
|
||||
.map((item) => ({ providerID: item.providerID, modelID: item.modelID }))
|
||||
}
|
||||
|
||||
type ModelSelection = ModelPreferenceModel & { variant?: string }
|
||||
|
||||
type AgentSelection = {
|
||||
id: string
|
||||
model?: { providerID: string; id: string; variant?: string }
|
||||
}
|
||||
|
||||
type SessionSelection = {
|
||||
agent?: string
|
||||
model?: { providerID: string; id: string; variant?: string }
|
||||
}
|
||||
|
||||
export function resolveAgentModelSelection(input: {
|
||||
selected?: ModelSelection
|
||||
agent?: AgentSelection
|
||||
session?: SessionSelection
|
||||
available: (model: ModelPreferenceModel) => boolean
|
||||
}) {
|
||||
const model = (value: SessionSelection["model"]): ModelSelection | undefined =>
|
||||
value && {
|
||||
providerID: value.providerID,
|
||||
modelID: value.id,
|
||||
variant: normalizeModelVariant(value.variant),
|
||||
}
|
||||
const candidates = [
|
||||
input.selected,
|
||||
input.session?.agent === input.agent?.id ? model(input.session?.model) : undefined,
|
||||
model(input.agent?.model),
|
||||
model(input.session?.model),
|
||||
]
|
||||
return candidates.find((item): item is ModelSelection => !!item && input.available(item))
|
||||
}
|
||||
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
@@ -100,6 +67,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
if (model && isModelValid(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
function createAgent() {
|
||||
const agents = createMemo(() =>
|
||||
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
|
||||
@@ -158,6 +132,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const agent = createAgent()
|
||||
|
||||
function createModel() {
|
||||
type ModelSelection = ModelPreferenceModel & { variant?: string }
|
||||
const [preferences, setPreferences] = createStore<ModelPreference & { ready: boolean }>({
|
||||
ready: false,
|
||||
recent: [],
|
||||
@@ -166,13 +141,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
const [selectionState, setSelectionState] = createStore<{
|
||||
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
|
||||
modelBySessionAgent: Record<string, Record<string, ModelSelection | undefined> | undefined>
|
||||
draftBySession: Record<string, ModelSelection | undefined>
|
||||
}>({
|
||||
newSessionModelByLocationAgent: {},
|
||||
modelBySessionAgent: {},
|
||||
draftBySession: {},
|
||||
})
|
||||
|
||||
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
|
||||
const pendingSelectionCommits = new Map<string, string>()
|
||||
const selectionKey = (value: ModelSelection) =>
|
||||
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
|
||||
const saveState = {
|
||||
pending: false,
|
||||
}
|
||||
@@ -230,19 +208,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
})
|
||||
|
||||
const newSessionSelection = createMemo<ModelSelection | undefined>(() => {
|
||||
const newSessionModel = createMemo(() => {
|
||||
const a = agent.current()
|
||||
const selected = a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)]
|
||||
const resolved = resolveAgentModelSelection({ selected, agent: a, available: isModelValid }) ?? fallbackModel()
|
||||
if (!resolved) return
|
||||
if (selected || !a?.model || resolved.providerID !== a.model.providerID || resolved.modelID !== a.model.id)
|
||||
return { ...resolved, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(resolved)]) }
|
||||
return resolved
|
||||
return getFirstValidModel(
|
||||
() => a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)],
|
||||
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
|
||||
fallbackModel,
|
||||
)
|
||||
})
|
||||
|
||||
const currentSelection = createMemo<ModelSelection | undefined>(() => {
|
||||
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
|
||||
return newSessionSelection()
|
||||
const model = newSessionModel()
|
||||
if (!model) return
|
||||
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
|
||||
})
|
||||
|
||||
const currentModel = createMemo(() => {
|
||||
@@ -256,23 +235,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
|
||||
}
|
||||
|
||||
function sessionSelection(sessionID: string) {
|
||||
const current = agent.current()
|
||||
return resolveAgentModelSelection({
|
||||
selected: current && selectionState.modelBySessionAgent[sessionID]?.[current.id],
|
||||
agent: current,
|
||||
session: data.session.get(sessionID),
|
||||
available: isModelValid,
|
||||
})
|
||||
function durableSelection(sessionID: string): ModelSelection | undefined {
|
||||
const model = data.session.get(sessionID)?.model
|
||||
if (!model) return
|
||||
return {
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variant: normalizeModelVariant(model.variant),
|
||||
}
|
||||
}
|
||||
|
||||
function setSessionSelection(sessionID: string, selection: ModelSelection) {
|
||||
const current = agent.current()
|
||||
if (!current) return
|
||||
setSelectionState("modelBySessionAgent", sessionID, {
|
||||
...selectionState.modelBySessionAgent[sessionID],
|
||||
[current.id]: selection,
|
||||
})
|
||||
function sessionSelection(sessionID: string) {
|
||||
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
|
||||
}
|
||||
|
||||
function setSessionDraft(sessionID: string, selection: ModelSelection) {
|
||||
const durable = durableSelection(sessionID)
|
||||
setSelectionState(
|
||||
"draftBySession",
|
||||
sessionID,
|
||||
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
|
||||
)
|
||||
}
|
||||
|
||||
function selectModel(model: ModelPreferenceModel) {
|
||||
@@ -286,7 +269,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
)
|
||||
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
|
||||
setSessionSelection(sessionID, { ...model, variant })
|
||||
setSessionDraft(sessionID, { ...model, variant })
|
||||
return true
|
||||
}
|
||||
const current = agent.current()
|
||||
@@ -295,9 +278,27 @@ 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)
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
event.on("session.deleted", (evt) => {
|
||||
setSelectionState("modelBySessionAgent", evt.data.sessionID, undefined)
|
||||
pendingSelectionCommits.delete(evt.data.sessionID)
|
||||
setSelectionState("draftBySession", evt.data.sessionID, undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -307,6 +308,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
available(model = currentModel()) {
|
||||
return model ? isModelValid(model) : false
|
||||
},
|
||||
trackSessionCommit(
|
||||
sessionID: string,
|
||||
value: {
|
||||
providerID: string
|
||||
id: string
|
||||
variant?: string
|
||||
},
|
||||
) {
|
||||
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
|
||||
pendingSelectionCommits.set(sessionID, committed)
|
||||
return () => {
|
||||
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
|
||||
}
|
||||
},
|
||||
get ready() {
|
||||
return preferences.ready
|
||||
},
|
||||
@@ -421,7 +436,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const m = currentSelection()
|
||||
if (!m) return
|
||||
if (route.data.type === "session") {
|
||||
setSessionSelection(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
|
||||
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
|
||||
}
|
||||
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||
savePreferences()
|
||||
|
||||
@@ -99,7 +99,7 @@ function View(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.home.footer",
|
||||
id: "opencode.home-footer",
|
||||
setup(context) {
|
||||
// Root takeover: an external plugin replacing home.footer wins (last-
|
||||
// enabled) and this builtin shows as suppressed, not silently gone.
|
||||
|
||||
@@ -83,7 +83,7 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.prompt.footer",
|
||||
id: "opencode.prompt-footer",
|
||||
setup(context) {
|
||||
context.ui.slot({
|
||||
append: "prompt.footer",
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SidebarContext(props: { context: Plugin.Context; sessionID: stri
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.sidebar.context",
|
||||
id: "internal:sidebar-context",
|
||||
setup(context) {
|
||||
context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
|
||||
@@ -40,7 +40,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.sidebar.footer",
|
||||
id: "opencode.sidebar-footer",
|
||||
setup(context) {
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
|
||||
@@ -71,7 +71,7 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.sidebar.mcp",
|
||||
id: "internal:sidebar-mcp",
|
||||
setup(context) {
|
||||
context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
|
||||
@@ -1079,7 +1079,7 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.diffs",
|
||||
id: "diff-viewer",
|
||||
setup(context) {
|
||||
context.ui.router.register({
|
||||
name: ROUTE,
|
||||
|
||||
@@ -29,7 +29,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
type: "plugin",
|
||||
id: "opencode.diffs",
|
||||
id: "diff-viewer",
|
||||
name: "diff",
|
||||
data: { mode: "working", sessionID: "session-1", returnRoute: startRoute },
|
||||
})
|
||||
@@ -207,7 +207,7 @@ async function renderDiffViewer(
|
||||
navigate(destination: Destination) {
|
||||
setCurrent(
|
||||
destination.type === "plugin" && !("id" in destination)
|
||||
? { ...destination, id: "opencode.diffs" }
|
||||
? { ...destination, id: "diff-viewer" }
|
||||
: destination,
|
||||
)
|
||||
},
|
||||
@@ -334,7 +334,7 @@ test("branch diff source requests branch VCS diff", async () => {
|
||||
const viewer = await renderDiffViewer([], {
|
||||
initialRoute: {
|
||||
type: "plugin",
|
||||
id: "opencode.diffs",
|
||||
id: "diff-viewer",
|
||||
name: "diff",
|
||||
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
},
|
||||
@@ -342,7 +342,7 @@ test("branch diff source requests branch VCS diff", async () => {
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
type: "plugin",
|
||||
id: "opencode.diffs",
|
||||
id: "diff-viewer",
|
||||
name: "diff",
|
||||
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { parseModel, recentModels, resolveAgentModelSelection } from "../../src/context/local"
|
||||
import { parseModel, recentModels } from "../../src/context/local"
|
||||
|
||||
test("parses model IDs containing slashes", () => {
|
||||
expect(parseModel("provider/family/model")).toEqual({
|
||||
@@ -20,47 +20,3 @@ test("moves a model to the front, deduplicates, and limits recents", () => {
|
||||
...recent.slice(6, 10),
|
||||
])
|
||||
})
|
||||
|
||||
test("uses the configured model when switching agents", () => {
|
||||
expect(
|
||||
resolveAgentModelSelection({
|
||||
agent: { id: "build", model: { providerID: "provider", id: "build-model", variant: "max" } },
|
||||
session: {
|
||||
agent: "plan",
|
||||
model: { providerID: "provider", id: "plan-model", variant: "high" },
|
||||
},
|
||||
available: () => true,
|
||||
}),
|
||||
).toEqual({ providerID: "provider", modelID: "build-model", variant: "max" })
|
||||
})
|
||||
|
||||
test("keeps a manual model selection for each session agent", () => {
|
||||
expect(
|
||||
resolveAgentModelSelection({
|
||||
selected: { providerID: "provider", modelID: "manual-model", variant: "high" },
|
||||
agent: { id: "build", model: { providerID: "provider", id: "build-model", variant: "max" } },
|
||||
session: { agent: "plan", model: { providerID: "provider", id: "plan-model" } },
|
||||
available: () => true,
|
||||
}),
|
||||
).toEqual({ providerID: "provider", modelID: "manual-model", variant: "high" })
|
||||
})
|
||||
|
||||
test("keeps the durable model while the active agent is unchanged", () => {
|
||||
expect(
|
||||
resolveAgentModelSelection({
|
||||
agent: { id: "plan", model: { providerID: "provider", id: "configured-model", variant: "max" } },
|
||||
session: { agent: "plan", model: { providerID: "provider", id: "manual-model", variant: "high" } },
|
||||
available: () => true,
|
||||
}),
|
||||
).toEqual({ providerID: "provider", modelID: "manual-model", variant: "high" })
|
||||
})
|
||||
|
||||
test("keeps the session model when the next agent has no configured model", () => {
|
||||
expect(
|
||||
resolveAgentModelSelection({
|
||||
agent: { id: "review" },
|
||||
session: { agent: "plan", model: { providerID: "provider", id: "plan-model", variant: "high" } },
|
||||
available: () => true,
|
||||
}),
|
||||
).toEqual({ providerID: "provider", modelID: "plan-model", variant: "high" })
|
||||
})
|
||||
|
||||
@@ -141,7 +141,15 @@ async function renderSessionTabs(
|
||||
locations,
|
||||
vcsLocations,
|
||||
state,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
emit: (event: OpenCodeEvent) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const off = client.event.listen((delivered) => {
|
||||
if (delivered.details.id !== event.id) return
|
||||
off()
|
||||
resolve()
|
||||
})
|
||||
events.emit({ ...event, location: { directory } })
|
||||
}),
|
||||
focus: () => app.renderer.emit("focus"),
|
||||
blur: () => app.renderer.emit("blur"),
|
||||
flush: () => storage.flush(),
|
||||
@@ -258,43 +266,31 @@ test("keeps scroll anchors for open session tabs", async () => {
|
||||
})
|
||||
|
||||
test("only the foreground TUI mutates unread state", async () => {
|
||||
await using temporary = await tmpdir()
|
||||
let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
let background: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
|
||||
try {
|
||||
foreground = await renderSessionTabs("first", { state: temporary.path, persisted: ["first", "second"] })
|
||||
background = await renderSessionTabs("second", { state: temporary.path })
|
||||
foreground = await renderSessionTabs("first", { persisted: ["first", "second"] })
|
||||
background = await renderSessionTabs("first", { persisted: ["first", "second"] })
|
||||
foreground.focus()
|
||||
background.blur()
|
||||
await wait(() => foreground?.tabs.tabs().length === 2 && background?.tabs.tabs().length === 2, 2_000, "shared tabs")
|
||||
|
||||
const firstDone = executionSucceeded("first")
|
||||
foreground.emit(firstDone)
|
||||
background.emit(firstDone)
|
||||
await Promise.all([foreground.emit(firstDone), background.emit(firstDone)])
|
||||
await Promise.all([foreground.flush(), background.flush()])
|
||||
expect(foreground.tabs.status("first").unread).toBeUndefined()
|
||||
expect(background.tabs.status("first").unread).toBeUndefined()
|
||||
|
||||
const secondDone = executionSucceeded("second")
|
||||
foreground.emit(secondDone)
|
||||
background.emit(secondDone)
|
||||
await wait(
|
||||
() =>
|
||||
foreground?.tabs.status("second").unread === "activity" &&
|
||||
background?.tabs.status("second").unread === "activity",
|
||||
10_000,
|
||||
"shared unread activity",
|
||||
)
|
||||
await Promise.all([foreground.emit(secondDone), background.emit(secondDone)])
|
||||
await Promise.all([foreground.flush(), background.flush()])
|
||||
expect(foreground.tabs.status("second").unread).toBe("activity")
|
||||
expect(background.tabs.status("second").unread).toBeUndefined()
|
||||
|
||||
foreground.tabs.select("second")
|
||||
await wait(
|
||||
() =>
|
||||
foreground?.tabs.status("second").unread === undefined &&
|
||||
background?.tabs.status("second").unread === undefined,
|
||||
10_000,
|
||||
"shared unread clearing",
|
||||
)
|
||||
await foreground.flush()
|
||||
expect(foreground.tabs.status("second").unread).toBeUndefined()
|
||||
expect(background.tabs.status("second").unread).toBeUndefined()
|
||||
} finally {
|
||||
if (foreground) await foreground.destroy()
|
||||
if (background) await background.destroy()
|
||||
|
||||
Reference in New Issue
Block a user