diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 9fcaa94b7f01..8bff0a9095b6 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -12,6 +12,7 @@ import { SessionMessage } from "./session/message.js" import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt" import { PromptInput } from "@opencode-ai/schema/prompt-input" import { Bus } from "./bus.js" +import { Catalog } from "./catalog.js" import { Database } from "./database/database.js" import { SessionProjector } from "./session/projector.js" import { SessionMessageTable, SessionTable } from "./session/sql.js" @@ -628,35 +629,40 @@ const layer = Layer.effect( }), command: Effect.fn("Session.command")(function* (input) { const session = yield* result.get(input.sessionID) - const commands = yield* Effect.gen(function* () { + const locationLayer = locations.get(session.location) + const resolved = yield* Effect.gen(function* () { const plugins = yield* PluginSupervisor.Service yield* plugins.flush - return yield* Command.Service - }).pipe(Effect.provide(locations.get(session.location))) - const command = yield* commands.get(input.command) - if (!command) - return yield* new Command.NotFoundError({ - command: input.command, - message: `Command not found: ${input.command}`, + const commands = yield* Command.Service + const command = yield* commands.get(input.command) + if (!command) + return yield* new Command.NotFoundError({ + command: input.command, + message: `Command not found: ${input.command}`, + }) + const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments }) + // TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands. + const agent = command.agent ?? input.agent + const commandAgent = yield* Effect.gen(function* () { + if (!command.agent) return undefined + const agents = yield* Agent.Service + return yield* agents.get(Agent.ID.make(command.agent)) }) - const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments }) - - // TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands. - const agent = command.agent ?? input.agent - const commandAgent = yield* Effect.gen(function* () { - if (!command.agent) return undefined - const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location))) - return yield* agents.get(Agent.ID.make(command.agent)) - }) - const model = command.model ?? commandAgent?.model ?? input.model - if (agent !== undefined && session.agent !== Agent.ID.make(agent)) - yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(agent) }) - if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model }) + const commandModel = command.model ?? commandAgent?.model + const model = commandModel + ? yield* inheritModelVariant(commandModel, input.model ?? session.model) + : input.model + return { agent, evaluated, model } + }).pipe(Effect.provide(locationLayer)) + if (resolved.agent !== undefined && session.agent !== Agent.ID.make(resolved.agent)) + yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(resolved.agent) }) + if (resolved.model !== undefined) + yield* result.switchModel({ sessionID: input.sessionID, model: resolved.model }) return yield* result.prompt({ id: input.id, sessionID: input.sessionID, - text: evaluated.text, + text: resolved.evaluated.text, files: input.files, agents: input.agents, skills: input.skills, @@ -1126,6 +1132,19 @@ function positiveInt(value: string | null) { return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined } +function inheritModelVariant(command: Model.Ref, selected: Model.Ref | undefined) { + if (command.variant !== undefined) return Effect.succeed(command) + const variant = selected?.variant ?? Model.VariantID.make("default") + if (variant === "default") return Effect.succeed({ ...command, variant }) + return Effect.gen(function* () { + const catalog = yield* Catalog.Service + const model = yield* catalog.model.get(command.providerID, command.id) + if (!model?.variants.some((item) => item.id === variant)) + return { ...command, variant: Model.VariantID.make("default") } + return { ...command, variant } + }) +} + // Mirrors the shell tool's in-memory preview safety limit. const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024 diff --git a/packages/core/test/session-command.test.ts b/packages/core/test/session-command.test.ts new file mode 100644 index 000000000000..127f54bfcf94 --- /dev/null +++ b/packages/core/test/session-command.test.ts @@ -0,0 +1,227 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer, LayerMap } from "effect" +import { Agent } from "@opencode-ai/core/agent" +import { Bus } from "@opencode-ai/core/bus" +import { Catalog } from "@opencode-ai/core/catalog" +import { Command } from "@opencode-ai/core/command" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import type { LocationServices } from "@opencode-ai/core/location-services" +import { Model } from "@opencode-ai/core/model" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import { Provider } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { Session } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionStore } from "@opencode-ai/core/session/store" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { testEffect } from "./lib/effect" + +const providerID = Provider.ID.make("openai") +const supplied = Model.Ref.make({ + providerID, + id: Model.ID.make("model-a"), + variant: Model.VariantID.make("xhigh"), +}) +const sameModel = Model.Ref.make({ providerID, id: supplied.id }) +const supported = Model.Ref.make({ providerID: Provider.ID.anthropic, id: Model.ID.make("model-b") }) +const unsupported = Model.Ref.make({ providerID: Provider.ID.make("google"), id: Model.ID.make("model-c") }) +const explicitAgent = Model.Ref.make({ ...supported, variant: Model.VariantID.make("high") }) +const explicitCommand = Model.Ref.make({ ...supported, variant: Model.VariantID.make("medium") }) +const variant = (id: string) => ({ id: Model.VariantID.make(id) }) +const catalogModels = [ + Model.Info.make({ ...Model.Info.default(sameModel.providerID, sameModel.id), variants: [variant("xhigh")] }), + Model.Info.make({ + ...Model.Info.default(supported.providerID, supported.id), + variants: [variant("xhigh"), variant("high"), variant("medium")], + }), + Model.Info.make({ ...Model.Info.default(unsupported.providerID, unsupported.id), variants: [variant("high")] }), +] + +const commands = new Map([ + ["inherit", Command.Info.make({ name: "inherit", template: "Inherited", agent: Agent.ID.make("orchestrator") })], + ["supported", Command.Info.make({ name: "supported", template: "Supported", model: supported })], + [ + "unsupported", + Command.Info.make({ name: "unsupported", template: "Unsupported", agent: Agent.ID.make("unsupported") }), + ], + [ + "agent-explicit", + Command.Info.make({ name: "agent-explicit", template: "Agent explicit", agent: Agent.ID.make("high-agent") }), + ], + [ + "command-explicit", + Command.Info.make({ + name: "command-explicit", + template: "Command explicit", + agent: Agent.ID.make("orchestrator"), + model: explicitCommand, + }), + ], +]) +const agents = new Map([ + [ + Agent.ID.make("orchestrator"), + Agent.Info.make({ ...Agent.Info.default(Agent.ID.make("orchestrator")), model: sameModel }), + ], + [ + Agent.ID.make("high-agent"), + Agent.Info.make({ ...Agent.Info.default(Agent.ID.make("high-agent")), model: explicitAgent }), + ], + [ + Agent.ID.make("unsupported"), + Agent.Info.make({ ...Agent.Info.default(Agent.ID.make("unsupported")), model: unsupported }), + ], +]) +const accesses: string[] = [] + +const locations = Layer.effect( + LocationServiceMap.Service, + LayerMap.make( + () => + Layer.unwrap( + Effect.sync(() => { + let ready = false + return Layer.mergeAll( + Layer.mock(Command.Service, { + get: (name) => + Effect.sync(() => { + accesses.push("command.get") + if (!ready) throw new Error("Command accessed before flush") + return commands.get(name) + }), + evaluate: (input) => + Effect.sync(() => { + accesses.push("command.evaluate") + if (!ready) throw new Error("Command evaluated before flush") + return { text: commands.get(input.name)?.template ?? "" } + }), + }), + Layer.mock(Agent.Service, { + get: (id) => + Effect.sync(() => { + accesses.push("agent.get") + if (!ready) throw new Error("Agent accessed before flush") + return agents.get(id) + }), + }), + Layer.mock(Catalog.Service, { + provider: { + get: () => Effect.succeed(undefined), + all: () => Effect.succeed([]), + available: () => Effect.succeed([]), + }, + model: { + get: (providerID, modelID) => + Effect.succeed( + catalogModels.find((model) => model.providerID === providerID && model.id === modelID), + ), + all: () => Effect.succeed(catalogModels), + available: () => Effect.succeed(catalogModels), + default: () => Effect.succeed(undefined), + small: () => Effect.succeed(undefined), + }, + }), + Layer.mock(PluginSupervisor.Service, { + flush: Effect.sync(() => { + accesses.push("flush") + ready = true + }), + }), + ) + }), + ) as unknown as Layer.Layer, + ), +) +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), + [ + [Bus.node, Bus.configured({ persist: true })], + [SessionExecution.node, SessionExecution.noopLayer], + [LocationServiceMap.node, locations], + ], + ), +) + +const run = (command: string, supplyModel = true) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + const sessionID = Session.ID.create() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "command-test", + directory: "/project", + title: "Command test", + version: "test", + model: supplied, + }) + .run() + .pipe(Effect.orDie) + const session = yield* Session.Service + yield* session.command({ sessionID, command, ...(supplyModel ? { model: supplied } : {}), resume: false }) + return yield* session.get(sessionID) + }) + +describe("Session.command", () => { + it.effect("flushes plugins before command and agent access", () => + Effect.gen(function* () { + accesses.length = 0 + yield* run("inherit") + expect(accesses).toEqual(["flush", "command.get", "command.evaluate", "agent.get"]) + }), + ) + + it.effect("preserves the supplied variant for the same command-agent model", () => + Effect.gen(function* () { + expect((yield* run("inherit")).model).toEqual(supplied) + }), + ) + + it.effect("preserves the selected variant when no model is supplied", () => + Effect.gen(function* () { + expect((yield* run("inherit", false)).model).toEqual(supplied) + }), + ) + + it.effect("carries the supplied variant to a different model that supports it", () => + Effect.gen(function* () { + expect((yield* run("supported")).model).toEqual({ ...supported, variant: supplied.variant }) + }), + ) + + it.effect("selects the destination default when a different model does not support the variant", () => + Effect.gen(function* () { + expect((yield* run("unsupported")).model).toEqual({ + ...unsupported, + variant: Model.VariantID.make("default"), + }) + }), + ) + + it.effect("honors an explicit command-agent variant", () => + Effect.gen(function* () { + expect((yield* run("agent-explicit")).model).toEqual(explicitAgent) + }), + ) + + it.effect("honors an explicit command variant", () => + Effect.gen(function* () { + expect((yield* run("command-explicit")).model).toEqual(explicitCommand) + }), + ) +})