diff --git a/packages/opencode/src/acp/config-option.ts b/packages/opencode/src/acp/config-option.ts index b730ae0753..ebf8baf131 100644 --- a/packages/opencode/src/acp/config-option.ts +++ b/packages/opencode/src/acp/config-option.ts @@ -61,8 +61,11 @@ export function buildEffortSelectOption(input: { description: "Available effort levels for this model", category: "thought_level", type: "select", - currentValue: selectVariant(input.currentVariant, input.variants), - options: input.variants.map((variant) => ({ + currentValue: + input.currentVariant === DEFAULT_VARIANT_VALUE + ? DEFAULT_VARIANT_VALUE + : selectVariant(input.currentVariant, input.variants), + options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({ value: variant, name: formatVariantName(variant), })), diff --git a/packages/opencode/src/acp/event.ts b/packages/opencode/src/acp/event.ts index dec34b8b66..1180d3f5b0 100644 --- a/packages/opencode/src/acp/event.ts +++ b/packages/opencode/src/acp/event.ts @@ -134,7 +134,7 @@ export class Subscription { sessionId: message.info.sessionID, update: { sessionUpdate, - messageId: message.info.id, + messageId: part.type === "reasoning" ? part.id : message.info.id, ...chunk, }, }) @@ -248,7 +248,7 @@ export class Subscription { sessionId: session.id, update: { sessionUpdate: "agent_thought_chunk", - messageId: props.messageID, + messageId: props.partID, content: { type: "text", text: props.delta, diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 55fbc9681d..c542e2b753 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -31,10 +31,10 @@ import { } from "@agentclientprotocol/sdk" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import type { AssistantMessage, Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Message, OpencodeClient, Session, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { Context, Effect, Layer, ManagedRuntime } from "effect" import * as ACPError from "./error" -import { buildConfigOptions, parseModelSelection } from "./config-option" +import { buildConfigOptions, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option" import { promptContentToParts } from "./content" import { Directory } from "./directory" import { ACPEvent } from "./event" @@ -210,7 +210,7 @@ export function make(input: { const loadSession = Effect.fn("ACP.loadSession")(function* (params: LoadSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) - yield* request( + const backing = yield* request( () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), "session", ) @@ -218,15 +218,18 @@ export function make(input: { () => input.sdk.session.messages({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), "session", ) - const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(snapshot) + const restored = restoreSession( + snapshot, + backing, + messages.map((item) => item.info), + ) const state = yield* session.load({ id: params.sessionId, cwd: params.cwd, mcpServers: params.mcpServers, - model, - variant: restored.variant ?? selectVariant(snapshot, model), - modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), + model: restored.model, + variant: restored.variant, + modeId: restored.modeId, }) sessionSnapshots.set(state.id, snapshot) @@ -236,7 +239,7 @@ export function make(input: { return { configOptions: configOptions(snapshot, { - model: state.model ?? model, + model: state.model ?? restored.model, variant: state.variant, modeId: state.modeId, }), @@ -291,7 +294,7 @@ export function make(input: { const resumeSession = Effect.fn("ACP.resumeSession")(function* (params: ResumeSessionRequest) { const snapshot = yield* directorySnapshot(params.cwd) - yield* request( + const backing = yield* request( () => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }), "session", ) @@ -303,15 +306,18 @@ export function make(input: { ), "session", ) - const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(snapshot) + const restored = restoreSession( + snapshot, + backing, + messages.map((item) => item.info), + ) const state = yield* session.load({ id: params.sessionId, cwd: params.cwd, mcpServers: params.mcpServers ?? [], - model, - variant: restored.variant ?? selectVariant(snapshot, model), - modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), + model: restored.model, + variant: restored.variant, + modeId: restored.modeId, }) sessionSnapshots.set(state.id, snapshot) @@ -320,7 +326,7 @@ export function make(input: { return { configOptions: configOptions(snapshot, { - model: state.model ?? model, + model: state.model ?? restored.model, variant: state.variant, modeId: state.modeId, }), @@ -371,15 +377,18 @@ export function make(input: { input.sdk.session.messages({ directory: params.cwd, sessionID: forked.id, limit: 20 }, { throwOnError: true }), "session", ) - const restored = restoreFromMessages(messages.map((item) => item.info)) - const model = restored.model ?? selectDefaultModel(snapshot) + const restored = restoreSession( + snapshot, + forked, + messages.map((item) => item.info), + ) const state = yield* session.load({ id: forked.id, cwd: params.cwd, mcpServers: params.mcpServers ?? [], - model, - variant: restored.variant ?? selectVariant(snapshot, model), - modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined), + model: restored.model, + variant: restored.variant, + modeId: restored.modeId, }) sessionSnapshots.set(state.id, snapshot) @@ -390,7 +399,7 @@ export function make(input: { return { sessionId: state.id, configOptions: configOptions(snapshot, { - model: state.model ?? model, + model: state.model ?? restored.model, variant: state.variant, modeId: state.modeId, }), @@ -408,23 +417,25 @@ export function make(input: { if (params.configId === "model") { const selected = yield* parseSelectedModel(snapshot, params.value) - const variant = selected.variant ?? selectVariant(snapshot, selected.model) + const variant = selectModelVariant(snapshot, current, selected) const state = yield* session .setVariant(params.sessionId, Directory.variants(snapshot, selected.model) ? variant : undefined) .pipe(Effect.andThen(session.setModel(params.sessionId, selected.model))) + const options = configOptions(snapshot, { + model: state.model ?? selected.model, + variant: state.variant, + modeId: state.modeId, + }) + yield* sendConfigOptionUpdate(input.connection, params.sessionId, options) return { - configOptions: configOptions(snapshot, { - model: state.model ?? selected.model, - variant: state.variant, - modeId: state.modeId, - }), + configOptions: options, } } if (params.configId === "effort") { const model = current.model ?? selectDefaultModel(snapshot) const variants = Directory.variants(snapshot, model) - if (!variants || !Object.keys(variants).includes(params.value)) { + if (!variants || !hasVariant(variants, params.value)) { return yield* new ACPError.InvalidEffortError({ effort: params.value }) } const state = yield* session.setVariant(params.sessionId, params.value) @@ -468,14 +479,18 @@ export function make(input: { const current = yield* session.get(params.sessionId) const snapshot = yield* configSnapshot(current) const selected = yield* parseSelectedModel(snapshot, params.modelId) - yield* session - .setVariant( - params.sessionId, - Directory.variants(snapshot, selected.model) - ? (selected.variant ?? selectVariant(snapshot, selected.model)) - : undefined, - ) + const state = yield* session + .setVariant(params.sessionId, selectModelVariant(snapshot, current, selected)) .pipe(Effect.andThen(session.setModel(params.sessionId, selected.model))) + yield* sendConfigOptionUpdate( + input.connection, + params.sessionId, + configOptions(snapshot, { + model: state.model ?? selected.model, + variant: state.variant, + modeId: state.modeId, + }), + ) return {} }) @@ -899,6 +914,24 @@ function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultMod return Object.keys(variants)[0] } +function selectModelVariant( + snapshot: Directory.Snapshot, + current: ACPSession.Info, + selected: { model: Directory.DefaultModel; variant?: string }, +) { + const variants = Directory.variants(snapshot, selected.model) + if (!variants) return + if (selected.variant) return selected.variant + if (sameModel(selected.model, current.model) && current.variant && hasVariant(variants, current.variant)) + return current.variant + return selectVariant(snapshot, selected.model) +} + +function hasVariant(variants: Directory.ModelVariants, variant: string) { + // "default" is also the persisted sentinel for no explicit variant override. + return variant === DEFAULT_VARIANT_VALUE || Object.hasOwn(variants, variant) +} + function configOptions(snapshot: Directory.Snapshot, session: ConfigState) { return buildConfigOptions({ providers: Object.values(snapshot.providers), @@ -909,6 +942,25 @@ function configOptions(snapshot: Directory.Snapshot, session: ConfigState) { }) } +function sendConfigOptionUpdate( + connection: ServiceConnection | undefined, + sessionId: string, + options: ReturnType, +) { + if (!connection) return Effect.void + return Effect.tryPromise({ + try: () => + connection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: options, + }, + }), + catch: () => undefined, + }).pipe(Effect.ignore) +} + function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) { const selected = parseModelSelection(modelId, Object.values(snapshot.providers)) const provider = snapshot.providers[ProviderV2.ID.make(selected.model.providerID)] @@ -1034,6 +1086,75 @@ function stableStringify(value: unknown): string { .join(",")}}` } +function restoreSession( + snapshot: Directory.Snapshot, + backing: Pick, + messages: MessageInfo[], +) { + const history = restoreFromMessages(messages) + const durable = restoreDurableModel(backing.model) + const model = restoreModel(snapshot, durable.model, history.model) + return { + model, + variant: restoreVariant(snapshot, model, durable, history), + modeId: restoreMode(snapshot, backing.agent, history.modeId), + } +} + +function restoreDurableModel(model: Session["model"] | undefined) { + if (!model) return {} + return { + model: { + providerID: ProviderV2.ID.make(model.providerID), + modelID: ModelV2.ID.make(model.id), + }, + variant: model.variant, + } +} + +function restoreModel( + snapshot: Directory.Snapshot, + durable: Directory.DefaultModel | undefined, + history: Directory.DefaultModel | undefined, +) { + if (durable && hasModel(snapshot, durable)) return durable + if (history && hasModel(snapshot, history)) return history + return selectDefaultModel(snapshot) +} + +function restoreVariant( + snapshot: Directory.Snapshot, + model: Directory.DefaultModel, + durable: { model?: Directory.DefaultModel; variant?: string }, + history: { model?: Directory.DefaultModel; variant?: string }, +) { + const variants = Directory.variants(snapshot, model) + if (!variants) return + if (sameModel(model, durable.model) && durable.variant && hasVariant(variants, durable.variant)) + return durable.variant + if (sameModel(model, history.model) && history.variant && hasVariant(variants, history.variant)) + return history.variant + return selectVariant(snapshot, model) +} + +function restoreMode(snapshot: Directory.Snapshot, durable: string | undefined, history: string | undefined) { + if (hasMode(snapshot, durable)) return durable + if (hasMode(snapshot, history)) return history + if (snapshot.availableModes.length > 0) return snapshot.defaultModeID +} + +function hasModel(snapshot: Directory.Snapshot, model: Directory.DefaultModel) { + return Boolean(snapshot.providers[model.providerID]?.models[model.modelID]) +} + +function hasMode(snapshot: Directory.Snapshot, modeId: string | undefined) { + return Boolean(modeId && snapshot.availableModes.some((mode) => mode.id === modeId)) +} + +function sameModel(left: Directory.DefaultModel, right: Directory.DefaultModel | undefined) { + return left.providerID === right?.providerID && left.modelID === right.modelID +} + function restoreFromMessages(messages: readonly MessageInfo[]) { const user = messages.findLast( (message) => message.role === "user" && message.model?.providerID && message.model.modelID, diff --git a/packages/opencode/test/acp/config-option.test.ts b/packages/opencode/test/acp/config-option.test.ts index 846cfadad6..8df7af32df 100644 --- a/packages/opencode/test/acp/config-option.test.ts +++ b/packages/opencode/test/acp/config-option.test.ts @@ -114,6 +114,17 @@ describe("acp config options", () => { expect(buildEffortSelectOption({ variants: [] })).toBeUndefined() }) + test("exposes an explicit default even when the provider only lists named variants", () => { + expect(buildEffortSelectOption({ variants: ["low", "medium"], currentVariant: "default" })).toMatchObject({ + currentValue: "default", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "default", name: "Default" }, + ], + }) + }) + test("builds the mode select option with descriptions when present", () => { expect( buildModeSelectOption({ diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 8271b28265..1806c4a27a 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -352,6 +352,40 @@ describe("acp event routing", () => { ).toEqual(["agent_thought_chunk", "agent_thought_chunk"]) }) + it("uses reasoning part ids as ACP thought message boundaries", async () => { + const harness = createHarness() + await createKnownSession(harness.session, "ses_reasoning", { + messageId: "msg_reasoning", + partId: "part_first", + partType: "reasoning", + }) + await Effect.runPromise( + harness.session.recordPartMetadata({ + sessionId: "ses_reasoning", + messageId: "msg_reasoning", + partId: "part_second", + partType: "reasoning", + role: "assistant", + }), + ) + + await harness.subscription.handle(textDelta("ses_reasoning", "msg_reasoning", "part_first", "First")) + await harness.subscription.handle(textDelta("ses_reasoning", "msg_reasoning", "part_second", "Second")) + + expect(harness.updates.map((update) => update.update)).toEqual([ + { + sessionUpdate: "agent_thought_chunk", + messageId: "part_first", + content: { type: "text", text: "First" }, + }, + { + sessionUpdate: "agent_thought_chunk", + messageId: "part_second", + content: { type: "text", text: "Second" }, + }, + ]) + }) + it("does not create extra subscriptions on repeated loadSession", async () => { const harness = createHarness() let subscription: ACPEvent.Subscription | undefined diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index d784709dbb..82305381bd 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -194,6 +194,20 @@ describe("ACP service sessions", () => { messages: readonly { info: unknown; parts: readonly unknown[] }[] = [], options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> + get?: () => Promise<{ + data: { + id: string + agent?: string + model?: { id: string; providerID: string; variant?: string } + } + }> + fork?: (input: { sessionID: string }) => Promise<{ + data: { + id: string + agent?: string + model?: { id: string; providerID: string; variant?: string } + } + }> prompt?: (input: unknown) => Promise<{ data: { info: ReturnType } }> sessionUpdate?: (update: SessionNotification) => Promise }, @@ -243,7 +257,7 @@ describe("ACP service sessions", () => { }, session: { create: () => Promise.resolve({ data: { id: "ses_new" } }), - get: () => Promise.resolve({ data: { id: "ses_loaded" } }), + get: options?.get ?? (() => Promise.resolve({ data: { id: "ses_loaded" } })), list: (input: { directory?: string }) => Promise.resolve({ data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions, @@ -292,7 +306,7 @@ describe("ACP service sessions", () => { }), fork: (input: { sessionID: string }) => { forks.push(input.sessionID) - return Promise.resolve({ data: { id: `fork_${input.sessionID}` } }) + return options?.fork?.(input) ?? Promise.resolve({ data: { id: `fork_${input.sessionID}` } }) }, }, mcp: { @@ -378,6 +392,149 @@ describe("ACP service sessions", () => { expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan") }) + it("restores durable model variant and mode before message history", async () => { + const { service } = makeService( + [ + { + info: { + role: "assistant", + providerID: "test", + modelID: "second-model", + variant: "medium", + mode: "build", + }, + parts: [], + }, + ], + { + get: () => + Promise.resolve({ + data: { + id: "ses_loaded", + agent: "plan", + model: { providerID: "test", id: "test-model", variant: "high" }, + }, + }), + }, + ) + + const loaded = await Effect.runPromise( + service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }), + ) + const resumed = await Effect.runPromise( + service.resumeSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }), + ) + + expect(select(loaded, "model")?.currentValue).toBe("test/test-model") + expect(select(loaded, "effort")?.currentValue).toBe("high") + expect(select(loaded, "mode")?.currentValue).toBe("plan") + expect(select(resumed, "model")?.currentValue).toBe("test/test-model") + expect(select(resumed, "effort")?.currentValue).toBe("high") + expect(select(resumed, "mode")?.currentValue).toBe("plan") + }) + + it.each(["loadSession", "resumeSession"] as const)("%s preserves default effort", async (method) => { + const { service, prompts, updates } = makeService( + [ + { + info: { + role: "user", + model: { providerID: "test", modelID: "second-model", variant: "medium" }, + agent: "build", + }, + parts: [], + }, + ], + { + get: () => + Promise.resolve({ + data: { + id: "ses_loaded", + agent: "build", + model: { providerID: "test", id: "second-model", variant: "default" }, + }, + }), + }, + ) + + const restored = await Effect.runPromise( + service[method]({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }), + ) + expect(select(restored, "effort")?.currentValue).toBe("default") + expect(flattenSelectOptions(select(restored, "effort")).map((option) => option.value)).toContain("default") + + await Effect.runPromise(service.setSessionModel({ sessionId: "ses_loaded", modelId: "test/second-model" })) + const update = updates.findLast((item) => item.update.sessionUpdate === "config_option_update")?.update + if (update?.sessionUpdate !== "config_option_update") throw new Error("missing config option update") + expect(select({ configOptions: update.configOptions }, "effort")?.currentValue).toBe("default") + + const synchronized = await Effect.runPromise( + service.setSessionConfigOption({ sessionId: "ses_loaded", configId: "model", value: "test/second-model" }), + ) + expect(select(synchronized, "effort")?.currentValue).toBe("default") + + await Effect.runPromise(service.prompt({ sessionId: "ses_loaded", prompt: [{ type: "text", text: "hello" }] })) + expect(prompts).toEqual([expect.objectContaining({ variant: "default" })]) + + await Effect.runPromise( + service.setSessionConfigOption({ sessionId: "ses_loaded", configId: "effort", value: "medium" }), + ) + const reset = await Effect.runPromise( + service.setSessionConfigOption({ sessionId: "ses_loaded", configId: "effort", value: "default" }), + ) + expect(select(reset, "effort")?.currentValue).toBe("default") + }) + + it("falls back from stale durable state to valid message state", async () => { + const { service } = makeService( + [ + { + info: { + role: "assistant", + providerID: "test", + modelID: "second-model", + variant: "medium", + mode: "plan", + }, + parts: [], + }, + ], + { + get: () => + Promise.resolve({ + data: { + id: "ses_loaded", + agent: "missing", + model: { providerID: "missing", id: "missing", variant: "missing" }, + }, + }), + }, + ) + + const loaded = await Effect.runPromise( + service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }), + ) + + expect(select(loaded, "model")?.currentValue).toBe("test/second-model") + expect(select(loaded, "effort")?.currentValue).toBe("medium") + expect(select(loaded, "mode")?.currentValue).toBe("plan") + }) + + it("restores default effort from history when durable model state is absent", async () => { + const { service } = makeService([ + { + info: { + role: "user", + model: { providerID: "test", modelID: "second-model", variant: "default" }, + agent: "build", + }, + parts: [], + }, + ]) + const resumed = await Effect.runPromise(service.resumeSession({ cwd: "/workspace", sessionId: "ses_loaded" })) + expect(select(resumed, "effort")?.currentValue).toBe("default") + }) + it("replays loaded session transcript chunks", async () => { const { service, updates } = makeService([ { @@ -418,6 +575,47 @@ describe("ACP service sessions", () => { ]) }) + it("replays reasoning parts as separate ACP thought messages", async () => { + const { service, updates } = makeService([ + { + info: { id: "msg_assistant", sessionID: "ses_loaded", role: "assistant" }, + parts: [ + { + id: "part_first", + sessionID: "ses_loaded", + messageID: "msg_assistant", + type: "reasoning", + text: "First", + time: { start: 1, end: 2 }, + }, + { + id: "part_second", + sessionID: "ses_loaded", + messageID: "msg_assistant", + type: "reasoning", + text: "Second", + time: { start: 3, end: 4 }, + }, + ], + }, + ]) + + await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })) + + expect(updates.map((item) => item.update).filter((item) => item.sessionUpdate === "agent_thought_chunk")).toEqual([ + { + sessionUpdate: "agent_thought_chunk", + messageId: "part_first", + content: { type: "text", text: "First" }, + }, + { + sessionUpdate: "agent_thought_chunk", + messageId: "part_second", + content: { type: "text", text: "Second" }, + }, + ]) + }) + it("lists sessions sorted by updated time with cursor support", async () => { const { service } = makeService() const first = await Effect.runPromise(service.listSessions({ cwd: "/workspace" })) @@ -559,6 +757,41 @@ describe("ACP service sessions", () => { expect(forks).toEqual(["ses_parent"]) }) + it("restores fork state from the durable fork before message history", async () => { + const { service } = makeService( + [ + { + info: { + role: "assistant", + providerID: "test", + modelID: "test-model", + variant: "default", + mode: "build", + }, + parts: [], + }, + ], + { + fork: (input) => + Promise.resolve({ + data: { + id: `fork_${input.sessionID}`, + agent: "plan", + model: { providerID: "test", id: "second-model", variant: "medium" }, + }, + }), + }, + ) + + const forked = await Effect.runPromise( + service.forkSession({ cwd: "/workspace", sessionId: "ses_parent", mcpServers: [] }), + ) + + expect(select(forked, "model")?.currentValue).toBe("test/second-model") + expect(select(forked, "effort")?.currentValue).toBe("medium") + expect(select(forked, "mode")?.currentValue).toBe("plan") + }) + it("restores model variant and mode from the latest user message", async () => { const { service } = makeService([ { @@ -772,7 +1005,7 @@ describe("ACP service sessions", () => { }) it("switches model and returns updated model and effort options", async () => { - const { service } = makeService() + const { service, updates } = makeService() const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) const updated = await Effect.runPromise( service.setSessionConfigOption({ @@ -784,7 +1017,49 @@ describe("ACP service sessions", () => { expect(select(updated, "model")?.currentValue).toBe("test/second-model") expect(select(updated, "effort")?.currentValue).toBe("low") - expect(flattenSelectOptions(select(updated, "effort")).map((option) => option.value)).toEqual(["low", "medium"]) + expect(flattenSelectOptions(select(updated, "effort")).map((option) => option.value)).toEqual([ + "low", + "medium", + "default", + ]) + expect(updates.findLast((item) => item.update.sessionUpdate === "config_option_update")?.update).toEqual({ + sessionUpdate: "config_option_update", + configOptions: updated.configOptions, + }) + }) + + it("publishes updated model-dependent options for legacy model changes", async () => { + const { service, updates } = makeService() + const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] })) + + await Effect.runPromise(service.setSessionModel({ sessionId: session.sessionId, modelId: "test/second-model" })) + + const update = updates.findLast((item) => item.update.sessionUpdate === "config_option_update")?.update + expect(update?.sessionUpdate).toBe("config_option_update") + if (update?.sessionUpdate !== "config_option_update") throw new Error("missing config option update") + expect(select({ configOptions: update.configOptions }, "model")?.currentValue).toBe("test/second-model") + expect(select({ configOptions: update.configOptions }, "effort")?.currentValue).toBe("low") + }) + + it("preserves restored effort when legacy clients synchronize the same model", async () => { + const { service, updates } = makeService([], { + get: () => + Promise.resolve({ + data: { + id: "ses_loaded", + agent: "build", + model: { providerID: "test", id: "test-model", variant: "high" }, + }, + }), + }) + await Effect.runPromise(service.resumeSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] })) + + await Effect.runPromise(service.setSessionModel({ sessionId: "ses_loaded", modelId: "test/test-model" })) + + const update = updates.findLast((item) => item.update.sessionUpdate === "config_option_update")?.update + expect(update?.sessionUpdate).toBe("config_option_update") + if (update?.sessionUpdate !== "config_option_update") throw new Error("missing config option update") + expect(select({ configOptions: update.configOptions }, "effort")?.currentValue).toBe("high") }) it("switches effort and returns the updated effort current value", async () => { @@ -1360,7 +1635,12 @@ function categories(result: NewSessionResponse | LoadSessionResponse) { } function select( - result: SetSessionConfigOptionResponse | ResumeSessionResponse | NewSessionResponse | ForkSessionResponse, + result: + | SetSessionConfigOptionResponse + | ResumeSessionResponse + | LoadSessionResponse + | NewSessionResponse + | ForkSessionResponse, id: string, ) { return result.configOptions?.find( diff --git a/packages/opencode/test/cli/acp/config-options.test.ts b/packages/opencode/test/cli/acp/config-options.test.ts index 0c712f0f23..4d6acf6381 100644 --- a/packages/opencode/test/cli/acp/config-options.test.ts +++ b/packages/opencode/test/cli/acp/config-options.test.ts @@ -71,7 +71,7 @@ describe("opencode acp config option subprocess", () => { expect(effort.category).toBe("thought_level") expect(effort.currentValue).toBe("low") - expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"]) + expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high", "default"]) }), 60_000, )