Compare commits

...

1 Commits

Author SHA1 Message Date
Filip 20a953b29c fix(cli): honor agent model across run targets 2026-08-14 13:49:53 +00:00
11 changed files with 560 additions and 169 deletions
+33 -6
View File
@@ -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
+59
View File
@@ -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" },
})
})
})
+86 -74
View File
@@ -154,28 +154,39 @@ export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly bounda
export type Endpoint5_7Output = Session.Info
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
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<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type SessionSelectOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
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<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
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<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_11Input = {
export type Endpoint5_11Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_11Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
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<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_12Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
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<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_13Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
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<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_14Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
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<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_15Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
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<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
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<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
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<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
export type Endpoint5_19Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
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<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
export type Endpoint5_20Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_20Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_22Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_23Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_24Output = void
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
export type Endpoint5_24Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_27Output = void
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_28Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
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<E = never> = (
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_29Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_29Input,
) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_30Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_30Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_30Input,
) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_31Input = {
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_31Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
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<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_32Input) => Stream.Stream<Endpoint5_32Output, E>
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type Endpoint5_32Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
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<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_34Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID }
export type Endpoint5_34Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_35Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -925,6 +936,7 @@ export interface SessionApi<E = never> {
readonly get: SessionGetOperation<E>
readonly remove: SessionRemoveOperation<E>
readonly fork: SessionForkOperation<E>
readonly select: SessionSelectOperation<E>
readonly switchAgent: SessionSwitchAgentOperation<E>
readonly switchModel: SessionSwitchModelOperation<E>
readonly rename: SessionRenameOperation<E>
+80 -69
View File
@@ -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<Endpoint5_8Output>()(
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<Endpoint5_9Output>()(
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<Endpoint5_10Output>()(
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<Endpoint5_11Output>()(
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<Endpoint5_12Output>()(
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<Endpoint5_12Output>()(
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
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<Endpoint5_13Output>()(
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
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<Endpoint5_14Output>()(
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
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<Endpoint5_15Output>()(
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
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<Endpoint5_16Output>()(
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
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<Endpoint5_17Output>()(
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
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<Endpoint5_18Output>()(
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
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<Endpoint5_20Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
preserveEffect<Endpoint5_21Output>()(
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<Endpoint5_22Output>()(
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<Endpoint5_23Output>()(
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<Endpoint5_24Output>()(
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<Endpoint5_25Output>()(
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<Endpoint5_26Output>()(
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<Endpoint5_27Output>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
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<Endpoint5_29Output>()(
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
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<Endpoint5_30Output>()(
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
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<Endpoint5_31Output>()(
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveStream<Endpoint5_32Output>()(
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<Endpoint5_32Output>()(
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
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<Endpoint5_33Output>()(
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
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) =>
@@ -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<SessionSelectOutput>(
{
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<SessionSwitchAgentOutput>(
{
+34 -8
View File
@@ -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"]
+48 -12
View File
@@ -204,6 +204,11 @@ export interface Interface {
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly select: (input: {
sessionID: SessionSchema.ID
agent: Agent.ID
model?: Model.Ref
}) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
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
+28
View File
@@ -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
+22
View File
@@ -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 = <I extends HttpApiMiddleware.AnyId, S>(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 },
+37
View File
@@ -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) {
+119
View File
@@ -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<never, never>({
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}