diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 13dab421d59..f2aa6a3ff6e 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -3690,7 +3690,7 @@ export type CommandListOutput = { readonly description?: string readonly agent?: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } - readonly subtask?: boolean + readonly subagent?: boolean }> } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 9d03d09c38d..8ffd12e0ef6 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -155,7 +155,7 @@ const layer = Layer.effect( const info = Option.getOrUndefined( ConfigMigrateV1.isV1(input) ? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo)) - : decodeInfo(input), + : decodeInfo(normalizeCommandAliases(input)), ) if (!info) return return new Document({ type: "document", path: filepath, info }) @@ -223,3 +223,21 @@ export const node = makeLocationNode({ layer, deps: [FSUtil.node, Global.node, Location.node, Policy.node], }) + +function normalizeCommandAliases(input: unknown) { + if (typeof input !== "object" || input === null || Array.isArray(input)) return input + const commands = (input as Record).commands + if (typeof commands !== "object" || commands === null || Array.isArray(commands)) return input + return { + ...input, + commands: Object.fromEntries( + Object.entries(commands).map(([name, command]) => { + if (typeof command !== "object" || command === null || Array.isArray(command)) return [name, command] + const data = command as Record + if (data.subagent !== undefined || typeof data.subtask !== "boolean") return [name, command] + const { subtask, ...rest } = data + return [name, { ...rest, subagent: subtask }] + }), + ), + } +} diff --git a/packages/core/src/config/command.ts b/packages/core/src/config/command.ts index 394079b1e98..a98f8898e7d 100644 --- a/packages/core/src/config/command.ts +++ b/packages/core/src/config/command.ts @@ -8,5 +8,5 @@ export class Info extends Schema.Class("ConfigV2.Command")({ agent: Schema.String.pipe(Schema.optional), model: Schema.String.pipe(Schema.optional), variant: Schema.String.pipe(Schema.optional), - subtask: Schema.Boolean.pipe(Schema.optional), + subagent: Schema.Boolean.pipe(Schema.optional), }) {} diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index f9b31f8e45a..0ba1459b971 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -40,7 +40,7 @@ export const Plugin = define({ if (command.variant !== undefined && item.model !== undefined) { item.model.variant = ModelV2.VariantID.make(command.variant) } - if (command.subtask !== undefined) item.subtask = command.subtask + if (command.subagent !== undefined) item.subagent = command.subagent }) } } @@ -70,7 +70,9 @@ function loadDirectory(fs: FSUtil.Interface, directory: string) { function decode(directory: string, filepath: string, content: string) { const markdown = ConfigMarkdown.parseOption(content) if (!markdown) return - const info = Option.getOrUndefined(decodeCommand({ ...markdown.data, template: markdown.content.trim() })) + const info = Option.getOrUndefined( + decodeCommand({ ...normalizeFrontmatter(markdown.data), template: markdown.content.trim() }), + ) if (!info) return return { name: path @@ -81,3 +83,9 @@ function decode(directory: string, filepath: string, content: string) { info, } } + +function normalizeFrontmatter(data: Record) { + if (data.subagent !== undefined || typeof data.subtask !== "boolean") return data + const { subtask, ...rest } = data + return { ...rest, subagent: subtask } +} diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 19896412380..36e9497b3a8 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -18,6 +18,7 @@ export const Plugin = define({ draft.update("review", (command) => { command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) command.description = "review changes [commit|branch|pr], defaults to uncommitted" + command.subagent = true }) }) }), diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 6932dbfd54c..4732d133d70 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -303,7 +303,8 @@ model: anthropic/claude-sonnet-4-6 - `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter. - `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments. -- Optional: `description`, `agent`, `model`, `variant`, `subtask`. +- Optional: `description`, `agent`, `model`, `variant`, `subagent`. +- `subtask` is still accepted for older commands and is treated like `subagent`. ## Plugins diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 108d36707a2..11dbf7454cc 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -45,6 +45,10 @@ import { Identifier } from "./util/identifier" import { Shell } from "./shell" import { KeyedMutex } from "./effect/keyed-mutex" +const SUBAGENT_COMMAND_STARTED = + "The command is running in a background subagent session. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress." +const SUBAGENT_COMMAND_NO_TEXT = "Subagent command completed without a text response." + export const RevertState = Revert.State export type RevertState = Revert.State @@ -524,14 +528,77 @@ const layer = Layer.effect( }) 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 agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(session.location))) + const commandAgent = command.agent ? yield* agents.get(AgentV2.ID.make(command.agent)) : undefined + const model = command.model ?? commandAgent?.model ?? input.model ?? session.model + const subagent = command.subagent ?? false + + if (subagent) { + const childAgent = AgentV2.ID.make(command.agent ?? "general") + const childAgentInfo = yield* agents.get(childAgent) + const title = command.description ?? input.command + const child = yield* result.create({ + parentID: input.sessionID, + title, + agent: childAgent, + model: command.model ?? childAgentInfo?.model ?? input.model ?? session.model, + }) + const completion = (state: "completed" | "error" | "cancelled", text: string) => + result.synthetic({ + sessionID: input.sessionID, + text: `\n${text}\n`, + description: command.description, + metadata: { command: input.command, subagent: true, sessionID: child.id }, + }) + const admitted = yield* result.prompt({ + id: input.id, + sessionID: child.id, + prompt: { text: evaluated.text, files: input.files, agents: input.agents }, + delivery: input.delivery, + resume: false, + }) + yield* jobs.start({ + id: child.id, + type: "subagent", + title, + metadata: { command: input.command, parentID: input.sessionID, agent: childAgent.toString() }, + run: Effect.gen(function* () { + yield* result.resume(child.id) + const messages = yield* result.messages({ sessionID: child.id, order: "desc", limit: 20 }) + const assistant = messages.find( + (message) => + message.type === "assistant" && message.time.completed !== undefined && message.error === undefined, + ) + if (assistant === undefined || assistant.type !== "assistant") return SUBAGENT_COMMAND_NO_TEXT + const text = assistant.content + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join("") + return text.length > 0 ? text : SUBAGENT_COMMAND_NO_TEXT + }).pipe(Effect.onInterrupt(() => result.interrupt(child.id))), + }) + yield* jobs.background(child.id) + yield* jobs.wait({ id: child.id }).pipe( + Effect.flatMap((result) => { + if (result.info?.status === "completed") + return completion("completed", result.info.output ?? SUBAGENT_COMMAND_NO_TEXT) + if (result.info?.status === "error") + return completion("error", result.info.error ?? "Subagent command failed") + if (result.info?.status === "cancelled") return completion("cancelled", "Subagent command cancelled") + return Effect.void + }), + Effect.forkIn(scope, { startImmediately: true }), + ) + yield* result.synthetic({ + sessionID: input.sessionID, + text: `\n${SUBAGENT_COMMAND_STARTED}\n`, + description: command.description, + metadata: { command: input.command, subagent: true, sessionID: child.id }, + }) + return admitted + } + const agent = command.agent ?? input.agent - const commandAgent = yield* Effect.gen(function* () { - if (!command.agent) return undefined - const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(session.location))) - return yield* agents.get(AgentV2.ID.make(command.agent)) - }) - const model = command.model ?? commandAgent?.model ?? input.model if (agent !== undefined && session.agent !== AgentV2.ID.make(agent)) yield* result.switchAgent({ sessionID: input.sessionID, agent }) if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model }) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 2a9e1c73832..8404e9b68c6 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -72,7 +72,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) { buffer: info.compaction.reserved, }, skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])], - commands: info.command, + commands: commands(info.command), instructions: info.instructions, references: info.references ?? info.reference, plugins: info.plugin?.map((plugin) => @@ -83,6 +83,17 @@ export function migrate(info: typeof ConfigV1.Info.Type) { } } +function commands(info?: typeof ConfigV1.Info.Type.command) { + if (!info) return undefined + return Object.fromEntries( + Object.entries(info).map(([name, command]) => { + if (command?.subtask === undefined) return [name, command] + const { subtask, ...rest } = command + return [name, { ...rest, subagent: subtask }] + }), + ) +} + function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly>) { const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries( tools ?? {}, diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index 362a8e5da5f..35c9b08f2ab 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -44,9 +44,16 @@ description: File review agent: reviewer model: anthropic/claude variant: high -subtask: true +subagent: true --- Review files`, + ) + await fs.writeFile( + path.join(tmp.path, "commands", "legacy.md"), + `--- +subtask: true +--- +Legacy review`, ) await fs.writeFile(path.join(tmp.path, "commands", "nested", "docs.md"), "Write docs") await fs.writeFile(path.join(tmp.path, "commands", "empty.md"), "") @@ -80,9 +87,10 @@ Review files`, id: ModelV2.ID.make("claude"), variant: ModelV2.VariantID.make("high"), }, - subtask: true, + subagent: true, }), CommandV2.Info.make({ name: "empty", template: "" }), + CommandV2.Info.make({ name: "legacy", template: "Legacy review", subagent: true }), CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }), ]) }), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index e46644abaee..66eaf09e8fa 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -149,7 +149,7 @@ describe("Config", () => { agent: "reviewer", model: "anthropic/claude", variant: "high", - subtask: true, + subagent: true, }, }) }), diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index be84975f644..fa08b26351f 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -40,6 +40,7 @@ describe("CommandPlugin.Plugin", () => { expect(yield* command.get("review")).toMatchObject({ name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted", + subagent: true, }) }), ) diff --git a/packages/core/test/session-command.test.ts b/packages/core/test/session-command.test.ts new file mode 100644 index 00000000000..32450e43163 --- /dev/null +++ b/packages/core/test/session-command.test.ts @@ -0,0 +1,134 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { CommandV2 } from "@opencode-ai/core/command" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { Job } from "@opencode-ai/core/job" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionStore } from "@opencode-ai/core/session/store" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const projects = Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), + directories: () => Effect.succeed([]), + commit: () => Effect.void, + }), +) + +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + Job.node, + SessionProjector.node, + SessionStore.node, + SessionV2.node, + LocationServiceMap.node, + ]), + [ + [ProjectV2.node, projects], + [ + SessionExecution.node, + Layer.succeed( + SessionExecution.Service, + SessionExecution.Service.of({ + active: Effect.succeed(new Set()), + resume: () => Effect.never, + wake: () => Effect.void, + interrupt: () => Effect.void, + awaitIdle: () => Effect.void, + }), + ), + ], + ], + ), +) + +const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.make("anthropic") }) + +function withTmp(f: (location: Location.Ref) => Effect.Effect) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap((tmp) => f(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })))) +} + +describe("SessionV2.command", () => { + it.effect("runs subagent commands as background child sessions", () => + withTmp((location) => + Effect.gen(function* () { + const sessions = yield* SessionV2.Service + const parent = yield* sessions.create({ location, model }) + const locations = yield* LocationServiceMap.Service + const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(parent.location))) + yield* commands.transform((draft) => { + draft.update("review", (command) => { + command.template = "Review this" + command.description = "review changes" + command.agent = "reviewer" + command.subagent = true + }) + }) + + const admitted = yield* sessions.command({ sessionID: parent.id, command: "review" }) + const children = yield* sessions.list({ parentID: parent.id }) + + expect(children.data).toHaveLength(1) + expect(children.data[0]).toMatchObject({ + parentID: parent.id, + title: "review changes", + agent: AgentV2.ID.make("reviewer"), + model, + }) + expect(admitted).toMatchObject({ sessionID: children.data[0]!.id, prompt: { text: "Review this" } }) + expect(yield* Job.Service.use((jobs) => jobs.get(children.data[0]!.id))).toMatchObject({ + id: children.data[0]!.id, + type: "subagent", + status: "running", + }) + expect(yield* sessions.messages({ sessionID: parent.id })).toEqual([ + expect.objectContaining({ type: "synthetic", text: expect.stringContaining(children.data[0]!.id) }), + ]) + }), + ), + ) + + it.effect("defaults subagent commands without an agent to the general agent", () => + withTmp((location) => + Effect.gen(function* () { + const sessions = yield* SessionV2.Service + const parent = yield* sessions.create({ location }) + const locations = yield* LocationServiceMap.Service + const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(parent.location))) + yield* commands.transform((draft) => { + draft.update("research", (command) => { + command.template = "Legacy task" + command.subagent = true + }) + }) + + const admitted = yield* sessions.command({ sessionID: parent.id, command: "research" }) + const children = yield* sessions.list({ parentID: parent.id }) + + expect(children.data).toHaveLength(1) + expect(children.data[0]).toMatchObject({ agent: AgentV2.ID.make("general") }) + expect(admitted).toMatchObject({ sessionID: children.data[0]!.id, prompt: { text: "Legacy task" } }) + }), + ), + ) +}) diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts index 81e37157c6a..9b4a8efb6f7 100644 --- a/packages/schema/src/command.ts +++ b/packages/schema/src/command.ts @@ -14,7 +14,7 @@ export const Info = Schema.Struct({ description: Schema.String.pipe(optional), agent: Schema.String.pipe(optional), model: Model.Ref.pipe(optional), - subtask: Schema.Boolean.pipe(optional), + subagent: Schema.Boolean.pipe(optional), }).annotate({ identifier: "CommandV2.Info" }) export const Event = { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d97eb59298f..1681bb06c73 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5506,7 +5506,7 @@ export type CommandV2Info = { description?: string agent?: string model?: ModelRef - subtask?: boolean + subagent?: boolean } export type SkillV2Info = { @@ -9461,7 +9461,7 @@ export type CommandV2Info2 = { description?: string agent?: string model?: ModelRef2 - subtask?: boolean + subagent?: boolean } export type SkillV2Info2 = {