Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 41 additions & 22 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
227 changes: 227 additions & 0 deletions packages/core/test/session-command.test.ts
Original file line number Diff line number Diff line change
@@ -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<LocationServices>,
),
)
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)
}),
)
})
Loading