Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 958e7f77f6 | |||
| 56eddcfa1f |
@@ -3778,7 +3778,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
|
||||
}>
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,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 })
|
||||
@@ -259,3 +259,21 @@ export const node = makeLocationNode({
|
||||
layer,
|
||||
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node],
|
||||
})
|
||||
|
||||
function normalizeCommandAliases(input: unknown) {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return input
|
||||
const commands = (input as Record<string, unknown>).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<string, unknown>
|
||||
if (data.subagent !== undefined || typeof data.subtask !== "boolean") return [name, command]
|
||||
const { subtask, ...rest } = data
|
||||
return [name, { ...rest, subagent: subtask }]
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@ export class Info extends Schema.Class<Info>("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),
|
||||
}) {}
|
||||
|
||||
@@ -42,7 +42,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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,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
|
||||
@@ -92,3 +94,9 @@ function decode(directory: string, filepath: string, content: string) {
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFrontmatter(data: Record<string, unknown>) {
|
||||
if (data.subagent !== undefined || typeof data.subtask !== "boolean") return data
|
||||
const { subtask, ...rest } = data
|
||||
return { ...rest, subagent: subtask }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ import { Shell } from "./shell"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/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
|
||||
|
||||
@@ -511,14 +515,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: `<subagent id="${child.id}" state="${state}" description="${title}">\n${text}\n</subagent>`,
|
||||
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<typeof part, { type: "text" }> => 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: `<subagent id="${child.id}" state="running" description="${title}">\n${SUBAGENT_COMMAND_STARTED}\n</subagent>`,
|
||||
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 })
|
||||
|
||||
@@ -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) =>
|
||||
@@ -82,6 +82,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<Record<string, boolean>>) {
|
||||
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
|
||||
tools ?? {},
|
||||
|
||||
@@ -46,9 +46,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"), "")
|
||||
@@ -94,9 +101,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" }),
|
||||
])
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ describe("Config", () => {
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
subagent: true,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -44,6 +44,7 @@ describe("CommandPlugin.Plugin", () => {
|
||||
expect(yield* command.get("review")).toMatchObject({
|
||||
name: "review",
|
||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||
subagent: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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<A, E, R>(f: (location: Location.Ref) => Effect.Effect<A, E, R>) {
|
||||
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" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
@@ -5512,7 +5512,7 @@ export type CommandV2Info = {
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
subtask?: boolean
|
||||
subagent?: boolean
|
||||
}
|
||||
|
||||
export type SkillV2Info = {
|
||||
@@ -9513,7 +9513,7 @@ export type CommandV2Info2 = {
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: ModelRef2
|
||||
subtask?: boolean
|
||||
subagent?: boolean
|
||||
}
|
||||
|
||||
export type SkillV2Info2 = {
|
||||
|
||||
Reference in New Issue
Block a user