diff --git a/packages/cli/src/run/run.ts b/packages/cli/src/run/run.ts index 0336d55d682a..ba9106ac70e9 100644 --- a/packages/cli/src/run/run.ts +++ b/packages/cli/src/run/run.ts @@ -1,5 +1,11 @@ import { Service, type Endpoint } from "@opencode-ai/client/effect/service" -import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise" +import { + OpenCode, + type ModelRef, + type OpenCodeClient, + type SessionInfo, + type SessionMessageAssistantTool, +} from "@opencode-ai/client/promise" import { FSUtil } from "@opencode-ai/util/fs-util" import { open } from "node:fs/promises" import path from "node:path" @@ -116,8 +122,13 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End return undefined }) if (!target) return - const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined - const variant = target.model?.variant + await applyRunSelection({ + client, + sessionID: target.session.id, + agent: input.agent, + model: target.model, + explicit: explicit !== undefined || options.variant !== undefined, + }) if (!target.resume && input.title !== undefined) { await client.session.rename({ sessionID: target.session.id, @@ -131,9 +142,6 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End location: target.location, message: prepared.message, files: prepared.files, - agent: target.agent, - model, - variant, thinking: input.thinking ?? false, format: input.format, auto: input.auto ?? false, @@ -144,6 +152,25 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End }).catch((error) => reportRunError(input, errorMessage(error), target.session.id)) } +/** @internal Exported for CLI boundary tests. */ +export function applyRunSelection(input: { + client: OpenCodeClient + sessionID: SessionInfo["id"] + agent?: string + model?: ModelRef + explicit: boolean +}) { + if (input.agent) + return input.client.session.select({ + sessionID: input.sessionID, + agent: input.agent, + model: input.explicit && input.model ? { type: "explicit", model: input.model } : { type: "configured" }, + }) + if (input.explicit && input.model) + return input.client.session.switchModel({ sessionID: input.sessionID, model: input.model }) + return Promise.resolve() +} + export function mergeInput(message: string | undefined, piped: string | undefined) { if (!message) return piped || undefined if (!piped) return message diff --git a/packages/cli/test/run/run.test.ts b/packages/cli/test/run/run.test.ts new file mode 100644 index 000000000000..bb87106c461c --- /dev/null +++ b/packages/cli/test/run/run.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { OpenCode } from "@opencode-ai/client/promise" +import { applyRunSelection } from "../../src/run/run" + +afterEach(() => mock.restore()) + +describe("run selection", () => { + test("resolves the configured model when an agent is explicit", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + const select = spyOn(client.session, "select").mockResolvedValue(undefined) + + await applyRunSelection({ client, sessionID: "ses_test", agent: "modelprobe", explicit: false }) + + expect(select).toHaveBeenCalledWith({ + sessionID: "ses_test", + agent: "modelprobe", + model: { type: "configured" }, + }) + }) + + test("selects an explicit agent and model atomically", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + const select = spyOn(client.session, "select").mockResolvedValue(undefined) + + await applyRunSelection({ + client, + sessionID: "ses_test", + agent: "modelprobe", + model: { providerID: "opencode", id: "deepseek-v4-flash-free" }, + explicit: true, + }) + + expect(select).toHaveBeenCalledWith({ + sessionID: "ses_test", + agent: "modelprobe", + model: { + type: "explicit", + model: { providerID: "opencode", id: "deepseek-v4-flash-free" }, + }, + }) + }) + + test("switches only the model when no agent is explicit", async () => { + const client = OpenCode.make({ baseUrl: "https://opencode.test" }) + const switchModel = spyOn(client.session, "switchModel").mockResolvedValue(undefined) + + await applyRunSelection({ + client, + sessionID: "ses_test", + model: { providerID: "opencode", id: "deepseek-v4-flash-free" }, + explicit: true, + }) + + expect(switchModel).toHaveBeenCalledWith({ + sessionID: "ses_test", + model: { providerID: "opencode", id: "deepseek-v4-flash-free" }, + }) + }) +}) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index d596aad0ec09..b65cbfbed6e8 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -154,28 +154,39 @@ export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly bounda export type Endpoint5_7Output = Session.Info export type SessionForkOperation = (input: Endpoint5_7Input) => Effect.Effect -export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID } +export type Endpoint5_8Input = { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: + | { readonly type: "preserve" } + | { readonly type: "configured" } + | { readonly type: "explicit"; readonly model: Model.Ref } +} export type Endpoint5_8Output = void -export type SessionSwitchAgentOperation = (input: Endpoint5_8Input) => Effect.Effect +export type SessionSelectOperation = (input: Endpoint5_8Input) => Effect.Effect -export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref } +export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID } export type Endpoint5_9Output = void -export type SessionSwitchModelOperation = (input: Endpoint5_9Input) => Effect.Effect +export type SessionSwitchAgentOperation = (input: Endpoint5_9Input) => Effect.Effect -export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string } +export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref } export type Endpoint5_10Output = void -export type SessionRenameOperation = (input: Endpoint5_10Input) => Effect.Effect +export type SessionSwitchModelOperation = (input: Endpoint5_10Input) => Effect.Effect -export type Endpoint5_11Input = { +export type Endpoint5_11Input = { readonly sessionID: Session.ID; readonly title: string } +export type Endpoint5_11Output = void +export type SessionRenameOperation = (input: Endpoint5_11Input) => Effect.Effect + +export type Endpoint5_12Input = { readonly sessionID: Session.ID readonly directory: AbsolutePath readonly workspaceID?: Workspace.ID | undefined readonly delivery?: SessionInbox.Delivery | undefined } -export type Endpoint5_11Output = void -export type SessionMoveOperation = (input: Endpoint5_11Input) => Effect.Effect +export type Endpoint5_12Output = void +export type SessionMoveOperation = (input: Endpoint5_12Input) => Effect.Effect -export type Endpoint5_12Input = { +export type Endpoint5_13Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly text: string @@ -186,10 +197,10 @@ export type Endpoint5_12Input = { readonly delivery?: SessionInbox.Delivery | undefined readonly resume?: boolean | undefined } -export type Endpoint5_12Output = SessionInbox.User -export type SessionPromptOperation = (input: Endpoint5_12Input) => Effect.Effect +export type Endpoint5_13Output = SessionInbox.User +export type SessionPromptOperation = (input: Endpoint5_13Input) => Effect.Effect -export type Endpoint5_13Input = { +export type Endpoint5_14Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly command: string @@ -202,19 +213,19 @@ export type Endpoint5_13Input = { readonly delivery?: SessionInbox.Delivery | undefined readonly resume?: boolean | undefined } -export type Endpoint5_13Output = SessionInbox.User -export type SessionCommandOperation = (input: Endpoint5_13Input) => Effect.Effect +export type Endpoint5_14Output = SessionInbox.User +export type SessionCommandOperation = (input: Endpoint5_14Input) => Effect.Effect -export type Endpoint5_14Input = { +export type Endpoint5_15Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly skill: Skill.ID readonly resume?: boolean | undefined } -export type Endpoint5_14Output = void -export type SessionSkillOperation = (input: Endpoint5_14Input) => Effect.Effect +export type Endpoint5_15Output = void +export type SessionSkillOperation = (input: Endpoint5_15Input) => Effect.Effect -export type Endpoint5_15Input = { +export type Endpoint5_16Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly text: string @@ -223,97 +234,97 @@ export type Endpoint5_15Input = { readonly delivery?: SessionInbox.Delivery | undefined readonly resume?: boolean | undefined } -export type Endpoint5_15Output = SessionInbox.Synthetic -export type SessionSyntheticOperation = (input: Endpoint5_15Input) => Effect.Effect +export type Endpoint5_16Output = SessionInbox.Synthetic +export type SessionSyntheticOperation = (input: Endpoint5_16Input) => Effect.Effect -export type Endpoint5_16Input = { +export type Endpoint5_17Input = { readonly sessionID: Session.ID readonly id?: Event.ID | undefined readonly command: string } -export type Endpoint5_16Output = void -export type SessionShellOperation = (input: Endpoint5_16Input) => Effect.Effect +export type Endpoint5_17Output = void +export type SessionShellOperation = (input: Endpoint5_17Input) => Effect.Effect -export type Endpoint5_17Input = { +export type Endpoint5_18Input = { readonly sessionID: Session.ID readonly id?: SessionMessage.ID | undefined readonly delivery?: SessionInbox.Delivery | undefined } -export type Endpoint5_17Output = SessionInbox.Compaction -export type SessionCompactOperation = (input: Endpoint5_17Input) => Effect.Effect +export type Endpoint5_18Output = SessionInbox.Compaction +export type SessionCompactOperation = (input: Endpoint5_18Input) => Effect.Effect -export type Endpoint5_18Input = { readonly sessionID: Session.ID } -export type Endpoint5_18Output = void -export type SessionWaitOperation = (input: Endpoint5_18Input) => Effect.Effect +export type Endpoint5_19Input = { readonly sessionID: Session.ID } +export type Endpoint5_19Output = void +export type SessionWaitOperation = (input: Endpoint5_19Input) => Effect.Effect -export type Endpoint5_19Input = { +export type Endpoint5_20Input = { readonly sessionID: Session.ID readonly messageID: SessionMessage.ID readonly files?: boolean | undefined } -export type Endpoint5_19Output = Session.Revert -export type SessionRevertStageOperation = (input: Endpoint5_19Input) => Effect.Effect - -export type Endpoint5_20Input = { readonly sessionID: Session.ID } -export type Endpoint5_20Output = void -export type SessionRevertClearOperation = (input: Endpoint5_20Input) => Effect.Effect +export type Endpoint5_20Output = Session.Revert +export type SessionRevertStageOperation = (input: Endpoint5_20Input) => Effect.Effect export type Endpoint5_21Input = { readonly sessionID: Session.ID } export type Endpoint5_21Output = void -export type SessionRevertCommitOperation = (input: Endpoint5_21Input) => Effect.Effect +export type SessionRevertClearOperation = (input: Endpoint5_21Input) => Effect.Effect export type Endpoint5_22Input = { readonly sessionID: Session.ID } -export type Endpoint5_22Output = ReadonlyArray -export type SessionContextOperation = (input: Endpoint5_22Input) => Effect.Effect +export type Endpoint5_22Output = void +export type SessionRevertCommitOperation = (input: Endpoint5_22Input) => Effect.Effect export type Endpoint5_23Input = { readonly sessionID: Session.ID } -export type Endpoint5_23Output = ReadonlyArray -export type SessionInboxListOperation = (input: Endpoint5_23Input) => Effect.Effect +export type Endpoint5_23Output = ReadonlyArray +export type SessionContextOperation = (input: Endpoint5_23Input) => Effect.Effect -export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } -export type Endpoint5_24Output = void -export type SessionInboxCancelOperation = (input: Endpoint5_24Input) => Effect.Effect +export type Endpoint5_24Input = { readonly sessionID: Session.ID } +export type Endpoint5_24Output = ReadonlyArray +export type SessionInboxListOperation = (input: Endpoint5_24Input) => Effect.Effect export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } export type Endpoint5_25Output = void -export type SessionInboxSteerOperation = (input: Endpoint5_25Input) => Effect.Effect +export type SessionInboxCancelOperation = (input: Endpoint5_25Input) => Effect.Effect export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } export type Endpoint5_26Output = void -export type SessionInboxQueueOperation = (input: Endpoint5_26Input) => Effect.Effect +export type SessionInboxSteerOperation = (input: Endpoint5_26Input) => Effect.Effect + +export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID } +export type Endpoint5_27Output = void +export type SessionInboxQueueOperation = (input: Endpoint5_27Input) => Effect.Effect -export type Endpoint5_27Input = { readonly sessionID: Session.ID } -export type Endpoint5_27Output = ReadonlyArray +export type Endpoint5_28Input = { readonly sessionID: Session.ID } +export type Endpoint5_28Output = ReadonlyArray export type SessionInstructionsEntryListOperation = ( - input: Endpoint5_27Input, -) => Effect.Effect + input: Endpoint5_28Input, +) => Effect.Effect -export type Endpoint5_28Input = { +export type Endpoint5_29Input = { readonly sessionID: Session.ID readonly key: InstructionEntry.Key readonly value: Schema.Json } -export type Endpoint5_28Output = void -export type SessionInstructionsEntryPutOperation = ( - input: Endpoint5_28Input, -) => Effect.Effect - -export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } export type Endpoint5_29Output = void -export type SessionInstructionsEntryRemoveOperation = ( +export type SessionInstructionsEntryPutOperation = ( input: Endpoint5_29Input, ) => Effect.Effect -export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string } -export type Endpoint5_30Output = { readonly text: string } -export type SessionGenerateOperation = (input: Endpoint5_30Input) => Effect.Effect +export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key } +export type Endpoint5_30Output = void +export type SessionInstructionsEntryRemoveOperation = ( + input: Endpoint5_30Input, +) => Effect.Effect + +export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly prompt: string } +export type Endpoint5_31Output = { readonly text: string } +export type SessionGenerateOperation = (input: Endpoint5_31Input) => Effect.Effect -export type Endpoint5_31Input = { +export type Endpoint5_32Input = { readonly sessionID: Session.ID readonly after?: Event.Seq | undefined readonly follow?: boolean | undefined } -export type Endpoint5_31Output = +export type Endpoint5_32Output = | ( | { readonly id: Event.ID @@ -902,19 +913,19 @@ export type Endpoint5_31Output = } ) | EventLog.Synced -export type SessionLogOperation = (input: Endpoint5_31Input) => Stream.Stream +export type SessionLogOperation = (input: Endpoint5_32Input) => Stream.Stream -export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined } -export type Endpoint5_32Output = void -export type SessionInterruptOperation = (input: Endpoint5_32Input) => Effect.Effect - -export type Endpoint5_33Input = { readonly sessionID: Session.ID } +export type Endpoint5_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined } export type Endpoint5_33Output = void -export type SessionBackgroundOperation = (input: Endpoint5_33Input) => Effect.Effect +export type SessionInterruptOperation = (input: Endpoint5_33Input) => Effect.Effect + +export type Endpoint5_34Input = { readonly sessionID: Session.ID } +export type Endpoint5_34Output = void +export type SessionBackgroundOperation = (input: Endpoint5_34Input) => Effect.Effect -export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } -export type Endpoint5_34Output = SessionMessage.Info -export type SessionMessageOperation = (input: Endpoint5_34Input) => Effect.Effect +export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID } +export type Endpoint5_35Output = SessionMessage.Info +export type SessionMessageOperation = (input: Endpoint5_35Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -925,6 +936,7 @@ export interface SessionApi { readonly get: SessionGetOperation readonly remove: SessionRemoveOperation readonly fork: SessionForkOperation + readonly select: SessionSelectOperation readonly switchAgent: SessionSwitchAgentOperation readonly switchModel: SessionSwitchModelOperation readonly rename: SessionRenameOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 295e76d4ef59..556d19fa94ca 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -86,6 +86,8 @@ import type { Endpoint5_33Output, Endpoint5_34Input, Endpoint5_34Output, + Endpoint5_35Input, + Endpoint5_35Output, Endpoint6_0Input, Endpoint6_0Output, Endpoint7_0Input, @@ -376,35 +378,43 @@ const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Inp const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) => preserveEffect()( - raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( - Effect.mapError(mapClientError), - ), + raw["session.select"]({ + params: { sessionID: input["sessionID"] }, + payload: { agent: input["agent"], model: input["model"] }, + }).pipe(Effect.mapError(mapClientError)), ) const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) => preserveEffect()( - raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( + raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( Effect.mapError(mapClientError), ), ) const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) => preserveEffect()( - raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe( + raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( Effect.mapError(mapClientError), ), ) const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) => preserveEffect()( + raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe( + Effect.mapError(mapClientError), + ), + ) + +const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) => + preserveEffect()( raw["session.move"]({ params: { sessionID: input["sessionID"] }, payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) => - preserveEffect()( +const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) => + preserveEffect()( raw["session.prompt"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -423,8 +433,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I ), ) -const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) => - preserveEffect()( +const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) => + preserveEffect()( raw["session.command"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -445,16 +455,16 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I ), ) -const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) => - preserveEffect()( +const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) => + preserveEffect()( raw["session.skill"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) => - preserveEffect()( +const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) => + preserveEffect()( raw["session.synthetic"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -471,16 +481,16 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I ), ) -const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) => - preserveEffect()( +const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) => + preserveEffect()( raw["session.shell"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], command: input["command"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) => - preserveEffect()( +const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) => + preserveEffect()( raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], delivery: input["delivery"] }, @@ -490,13 +500,13 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I ), ) -const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) => - preserveEffect()( +const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) => + preserveEffect()( raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) => - preserveEffect()( +const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) => + preserveEffect()( raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -506,27 +516,19 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I ), ) -const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) => - preserveEffect()( - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), - ) - const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) => preserveEffect()( - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) => preserveEffect()( - raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( - Effect.mapError(mapClientError), - Effect.map((value) => value.data), - ), + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) => preserveEffect()( - raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), @@ -534,58 +536,66 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) => preserveEffect()( - raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( + raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), + Effect.map((value) => value.data), ), ) const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) => preserveEffect()( - raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( + raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( Effect.mapError(mapClientError), ), ) const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) => preserveEffect()( - raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( + raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( Effect.mapError(mapClientError), ), ) const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) => preserveEffect()( - raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe( Effect.mapError(mapClientError), - Effect.map((value) => value.data), ), ) const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) => preserveEffect()( + raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ), + ) + +const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) => + preserveEffect()( raw["session.instructions.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) => - preserveEffect()( +const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => + preserveEffect()( raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ), ) -const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) => - preserveEffect()( +const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) => + preserveEffect()( raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ), ) -const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) => - preserveStream()( +const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) => + preserveStream()( Stream.unwrap( raw["session.log"]({ params: { sessionID: input["sessionID"] }, @@ -597,21 +607,21 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I ), ) -const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) => - preserveEffect()( +const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) => + preserveEffect()( raw["session.interrupt"]({ params: { sessionID: input["sessionID"] }, query: { continue: input["continue"] }, }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) => - preserveEffect()( +const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) => + preserveEffect()( raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) => - preserveEffect()( +const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) => + preserveEffect()( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -627,26 +637,27 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({ get: Endpoint5_5(raw), remove: Endpoint5_6(raw), fork: Endpoint5_7(raw), - switchAgent: Endpoint5_8(raw), - switchModel: Endpoint5_9(raw), - rename: Endpoint5_10(raw), - move: Endpoint5_11(raw), - prompt: Endpoint5_12(raw), - command: Endpoint5_13(raw), - skill: Endpoint5_14(raw), - synthetic: Endpoint5_15(raw), - shell: Endpoint5_16(raw), - compact: Endpoint5_17(raw), - wait: Endpoint5_18(raw), - revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) }, - context: Endpoint5_22(raw), - inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) }, - instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } }, - generate: Endpoint5_30(raw), - log: Endpoint5_31(raw), - interrupt: Endpoint5_32(raw), - background: Endpoint5_33(raw), - message: Endpoint5_34(raw), + select: Endpoint5_8(raw), + switchAgent: Endpoint5_9(raw), + switchModel: Endpoint5_10(raw), + rename: Endpoint5_11(raw), + move: Endpoint5_12(raw), + prompt: Endpoint5_13(raw), + command: Endpoint5_14(raw), + skill: Endpoint5_15(raw), + synthetic: Endpoint5_16(raw), + shell: Endpoint5_17(raw), + compact: Endpoint5_18(raw), + wait: Endpoint5_19(raw), + revert: { stage: Endpoint5_20(raw), clear: Endpoint5_21(raw), commit: Endpoint5_22(raw) }, + context: Endpoint5_23(raw), + inbox: { list: Endpoint5_24(raw), cancel: Endpoint5_25(raw), steer: Endpoint5_26(raw), queue: Endpoint5_27(raw) }, + instructions: { entry: { list: Endpoint5_28(raw), put: Endpoint5_29(raw), remove: Endpoint5_30(raw) } }, + generate: Endpoint5_31(raw), + log: Endpoint5_32(raw), + interrupt: Endpoint5_33(raw), + background: Endpoint5_34(raw), + message: Endpoint5_35(raw), }) const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 55873f70d61b..ee902f4508ae 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -26,6 +26,8 @@ import type { SessionRemoveOutput, SessionForkInput, SessionForkOutput, + SessionSelectInput, + SessionSelectOutput, SessionSwitchAgentInput, SessionSwitchAgentOutput, SessionSwitchModelInput, @@ -557,6 +559,18 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + select: (input: SessionSelectInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/selection`, + body: { agent: input["agent"], model: input["model"] }, + successStatus: 204, + declaredStatuses: [404, 503, 400, 401], + empty: true, + }, + requestOptions, + ), switchAgent: (input: SessionSwitchAgentInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 8eb68bc84bb8..f770cb3fe52a 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2194,6 +2194,14 @@ export type MessageNotFoundError = { export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError" +export type ServiceUnavailableError = { + readonly _tag: "ServiceUnavailableError" + readonly message: string + readonly service?: string | undefined +} +export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError" + export type CommandNotFoundError = { readonly _tag: "CommandNotFoundError" readonly command: string @@ -2218,14 +2226,6 @@ export type SkillNotFoundError = { export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError" -export type ServiceUnavailableError = { - readonly _tag: "ServiceUnavailableError" - readonly message: string - readonly service?: string | undefined -} -export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError => - typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError" - export type SessionBusyError = { readonly _tag: "SessionBusyError" readonly sessionID: string @@ -3343,6 +3343,32 @@ export type SessionForkInput = { export type SessionForkOutput = { data: SessionInfo }["data"] +export type SessionSelectInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly agent: { + readonly agent: string + readonly model: + | { readonly type: "preserve" } + | { readonly type: "configured" } + | { + readonly type: "explicit" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + }["agent"] + readonly model: { + readonly agent: string + readonly model: + | { readonly type: "preserve" } + | { readonly type: "configured" } + | { + readonly type: "explicit" + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + }["model"] +} + +export type SessionSelectOutput = void + export type SessionSwitchAgentInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly agent: { readonly agent: string }["agent"] diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 45b3856fbb35..8394de89c1e1 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -204,6 +204,11 @@ export interface Interface { }) => Stream.Stream readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect + readonly select: (input: { + sessionID: SessionSchema.ID + agent: Agent.ID + model?: Model.Ref + }) => Effect.Effect readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect readonly move: (input: { sessionID: SessionSchema.ID @@ -701,22 +706,45 @@ const layer = Layer.effect( .resume(input.sessionID) .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) }), - switchAgent: Effect.fn("Session.switchAgent")(function* (input) { + select: Effect.fn("Session.select")(function* (input) { const session = yield* result.get(input.sessionID) - yield* bus.publish(SessionEvent.AgentSelected, { - sessionID: input.sessionID, - agent: input.agent, - previous: session.agent, - }) + const agent = + session.agent === input.agent + ? undefined + : { + sessionID: input.sessionID, + agent: input.agent, + previous: session.agent, + } + const model = !input.model || sameModel(session.model, input.model) ? undefined : input.model + if (agent && model) { + yield* bus.publishAll([ + [SessionEvent.AgentSelected, agent], + [ + SessionEvent.ModelSelected, + { + sessionID: input.sessionID, + model, + previous: session.model, + }, + ], + ]) + return + } + if (agent) yield* bus.publish(SessionEvent.AgentSelected, agent) + if (model) + yield* bus.publish(SessionEvent.ModelSelected, { + sessionID: input.sessionID, + model, + previous: session.model, + }) + }), + switchAgent: Effect.fn("Session.switchAgent")(function* (input) { + yield* result.select({ sessionID: input.sessionID, agent: input.agent }) }), switchModel: Effect.fn("Session.switchModel")(function* (input) { const session = yield* result.get(input.sessionID) - if ( - session.model?.providerID === input.model.providerID && - session.model.id === input.model.id && - (session.model.variant ?? "default") === (input.model.variant ?? "default") - ) - return + if (sameModel(session.model, input.model)) return yield* bus.publish(SessionEvent.ModelSelected, { sessionID: input.sessionID, model: input.model, @@ -1084,6 +1112,14 @@ function positiveInt(value: string | null) { return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined } +function sameModel(left: Model.Ref | undefined, right: Model.Ref) { + return ( + left?.providerID === right.providerID && + left.id === right.id && + (left.variant ?? "default") === (right.variant ?? "default") + ) +} + // Mirrors the shell tool's in-memory preview safety limit. const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024 diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 66a115083093..d79384698c43 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -728,6 +728,34 @@ describe("Session.create", () => { }), ) + it.effect("atomically selects a different agent and model", () => + Effect.gen(function* () { + const session = yield* Session.Service + const previous = Model.Ref.make({ id: Model.ID.make("haiku"), providerID: Provider.ID.anthropic }) + const created = yield* session.create({ location, agent: Agent.ID.make("build"), model: previous }) + const model = Model.Ref.make({ id: Model.ID.make("sonnet"), providerID: Provider.ID.anthropic }) + + yield* session.select({ sessionID: created.id, agent: Agent.ID.make("plan"), model }) + + expect(yield* session.get(created.id)).toMatchObject({ agent: "plan", model }) + expect( + Array.from(yield* logEvents(session, created.id).pipe(Stream.runCollect)).map((event) => event.type), + ).toEqual(["session.created", "session.agent.selected", "session.model.selected"]) + }), + ) + + it.effect("does not emit redundant selection events", () => + Effect.gen(function* () { + const session = yield* Session.Service + const model = Model.Ref.make({ id: Model.ID.make("sonnet"), providerID: Provider.ID.anthropic }) + const created = yield* session.create({ location, agent: Agent.ID.make("build"), model }) + + yield* session.select({ sessionID: created.id, agent: Agent.ID.make("build"), model }) + + expect(Array.from(yield* logEvents(session, created.id).pipe(Stream.runCollect))).toHaveLength(1) + }), + ) + it.effect("rejects an agent switch for a missing Session", () => Effect.gen(function* () { const session = yield* Session.Service diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 30677d584148..b27d0160010a 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -54,6 +54,12 @@ const SessionsQueryFields = { parentID: ParentIDFilter.pipe(Schema.optional), } +const SelectionModelPolicy = Schema.Union([ + Schema.Struct({ type: Schema.Literal("preserve") }), + Schema.Struct({ type: Schema.Literal("configured") }), + Schema.Struct({ type: Schema.Literal("explicit"), model: Model.Ref }), +]) + const SessionsDirectoryQuery = Schema.Struct({ ...SessionsQueryFields, directory: AbsolutePath, @@ -250,6 +256,22 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.select", "/api/session/:sessionID/selection", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ agent: Agent.ID, model: SelectionModelPolicy }), + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, ServiceUnavailableError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.select", + summary: "Select session agent and model", + description: "Atomically select the agent and model policy used by subsequent provider turns.", + }), + ), + ) .add( HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", { params: { sessionID: Session.ID }, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 5813663bf549..9915683522bb 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,4 +1,6 @@ import { Session } from "@opencode-ai/core/session" +import { Agent } from "@opencode-ai/core/agent" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { SessionTransfer } from "@opencode-ai/core/session/transfer" import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" import { DateTime, Effect, Stream } from "effect" @@ -226,6 +228,41 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.select", + Effect.fn(function* (ctx) { + const model = yield* Effect.gen(function* () { + if (ctx.payload.model.type === "preserve") return undefined + if (ctx.payload.model.type === "explicit") return ctx.payload.model.model + const plugins = yield* PluginSupervisor.Service + yield* plugins.flush.pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => + Effect.fail( + new ServiceUnavailableError({ + message: "Agent initialization timed out", + service: "agent.catalog", + }), + ), + }), + ) + const agents = yield* Agent.Service + return (yield* agents.get(ctx.payload.agent))?.model + }) + yield* session.select({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent, model }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.switchAgent", Effect.fn(function* (ctx) { diff --git a/packages/server/test/session.test.ts b/packages/server/test/session.test.ts new file mode 100644 index 000000000000..7018644f430f --- /dev/null +++ b/packages/server/test/session.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { expect } from "bun:test" +import { Effect } from "effect" +import { HttpServer } from "effect/unstable/http" +import { tmpdir } from "../../core/test/fixture/tmpdir" +import { it } from "../../core/test/lib/effect" +import { ServerProcess } from "../src/process" + +it.live("resolves configured agent models after plugin initialization", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir("opencode-session-endpoint-")), + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + fs.writeFile( + path.join(tmp.path, "opencode.json"), + JSON.stringify({ + agents: { + modelprobe: { + description: "Model resolution probe", + mode: "primary", + model: "opencode/nemotron-3.5-lightning-free", + }, + plain: { description: "No configured model", mode: "primary" }, + }, + }), + ), + ) + const server = yield* ServerProcess.start({ + hostname: "127.0.0.1", + port: 0, + password: "secret", + app: { version: "test-version" }, + database: { path: ":memory:" }, + config: { directory: tmp.path }, + fs: { filewatcher: false }, + }) + const base = HttpServer.formatAddress(server.address) + const created = yield* request(base, "/api/session", { + agent: "modelprobe", + location: { directory: tmp.path }, + }) + if (!isRecord(created) || !isRecord(created["data"])) throw new Error("Expected a created session") + expect(created["data"]["model"]).toBeUndefined() + const sessionID = created["data"]["id"] + if (typeof sessionID !== "string") throw new Error("Expected a Session ID") + + expect( + yield* request(base, `/api/session/${sessionID}/selection`, { + agent: "modelprobe", + model: { type: "configured" }, + }), + ).toBeUndefined() + const configured = yield* get(base, `/api/session/${sessionID}`) + if (!isRecord(configured) || !isRecord(configured["data"])) throw new Error("Expected a Session") + expect(configured["data"]).toMatchObject({ + agent: "modelprobe", + model: { + providerID: "opencode", + id: "nemotron-3.5-lightning-free", + variant: "default", + }, + }) + + expect( + yield* request(base, `/api/session/${sessionID}/selection`, { + agent: "plain", + model: { type: "configured" }, + }), + ).toBeUndefined() + const preserved = yield* get(base, `/api/session/${sessionID}`) + if (!isRecord(preserved) || !isRecord(preserved["data"])) throw new Error("Expected a Session") + expect(preserved["data"]).toMatchObject({ agent: "plain", model: configured["data"]["model"] }) + + expect( + yield* request(base, `/api/session/${sessionID}/selection`, { + agent: "missing", + model: { type: "configured" }, + }), + ).toBeUndefined() + const missing = yield* get(base, `/api/session/${sessionID}`) + if (!isRecord(missing) || !isRecord(missing["data"])) throw new Error("Expected a Session") + expect(missing["data"]).toMatchObject({ agent: "missing", model: configured["data"]["model"] }) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), +) + +function request(base: string, pathname: string, body: unknown) { + return Effect.promise(() => + fetch(new URL(pathname, base), { + method: "POST", + headers: { + authorization: `Basic ${btoa("opencode:secret")}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }).then(async (response) => { + expect(response.status).toBe(pathname === "/api/session" ? 200 : 204) + return response.status === 204 ? undefined : response.json() + }), + ) +} + +function get(base: string, pathname: string) { + return Effect.promise(() => + fetch(new URL(pathname, base), { + headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + }).then((response) => { + expect(response.status).toBe(200) + return response.json() + }), + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +}