Compare commits

...
4 Commits
10 changed files with 465 additions and 65 deletions
@@ -2045,6 +2045,7 @@ export type ConfigEntry =
experimental?: {
portable_shell_scanner?: boolean
subagent_depth?: number
subagent_fork?: boolean
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
}
}
+9
View File
@@ -426,6 +426,15 @@ function normalizeExperimental(
)
if (value !== undefined) result.subagent_depth = value
}
if (own(experimental, "subagent_fork")) {
const value = decodeEncoded(
ConfigExperimental.Info.fields.subagent_fork,
experimental.subagent_fork,
["experimental", "subagent_fork"],
diagnostics,
)
if (value !== undefined) result.subagent_fork = value
}
native.push(
...decodeList(
experimental.policies,
+4 -1
View File
@@ -93,6 +93,7 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
type ForkInput = {
sessionID: SessionSchema.ID
boundary: SessionSchema.ForkRequestBoundary
parentID?: SessionSchema.ID
}
export {
@@ -311,7 +312,9 @@ const layer = Layer.effect(
messageID: input.boundary.messageID,
})
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
const sessionID = SessionSchema.ID.create()
const sessionID = input.parentID
? (yield* result.create({ parentID: input.parentID })).id
: SessionSchema.ID.create()
const inherited = yield* db
.transaction(() =>
Effect.all({
+20 -12
View File
@@ -1,6 +1,6 @@
export * as SessionProjector from "./projector.js"
import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { Database } from "../database/database.js"
@@ -144,22 +144,25 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie)
const copiedSeq = copied?.seq
const inherited = {
fork_session_id: event.data.parentID,
fork_boundary: event.data.boundary,
project_id: parent.project_id,
workspace_id: parent.workspace_id,
directory: parent.directory,
path: parent.path,
title: forkTitle(parent.title ?? undefined),
agent: parent.agent,
model: parent.model,
metadata: parent.metadata,
}
const stored = yield* db
.insert(SessionTable)
.values({
id: event.data.sessionID,
parent_id: null,
fork_session_id: event.data.parentID,
fork_boundary: event.data.boundary,
project_id: parent.project_id,
workspace_id: parent.workspace_id,
...inherited,
slug: Slug.create(),
directory: parent.directory,
path: parent.path,
title: forkTitle(parent.title ?? undefined),
agent: parent.agent,
model: parent.model,
metadata: parent.metadata,
version: parent.version,
cost: 0,
tokens_input: 0,
@@ -170,7 +173,12 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
time_created: event.created,
time_updated: event.created,
})
.onConflictDoNothing()
// Created records optional ownership; Forked supplies the source's history and defaults.
.onConflictDoUpdate({
target: SessionTable.id,
set: { ...inherited, time_updated: event.created },
setWhere: and(isNotNull(SessionTable.parent_id), isNull(SessionTable.fork_session_id)),
})
.returning({ sessionID: SessionTable.id })
.get()
.pipe(Effect.orDie)
+63 -19
View File
@@ -5,6 +5,7 @@ import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { ConfigEntryObserver } from "../../config/plugin/entry-observer.js"
import { Job } from "../../job.js"
import { Permission } from "../../permission.js"
import { Session } from "../../session.js"
@@ -38,6 +39,13 @@ export const Input = Schema.Struct({
}),
})
const ForkInput = Schema.Struct({
...Input.fields,
fork: Schema.optionalKey(Schema.Boolean).annotate({
description: "Give the subagent your conversation history before this response.",
}),
})
export const Output = Schema.Struct({
sessionID: SessionSchema.ID,
status: Schema.Literals(["completed", "running"]),
@@ -61,17 +69,26 @@ export const Plugin = {
const config = yield* Config.Service
const permission = yield* Permission.Service
const subagents = yield* SubagentJob.make
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.tool.reload())
yield* ctx.tool
.transform((editor) =>
.transform((editor) => {
const fork = Config.latest(loaded.entries, "experimental")?.subagent_fork === true
editor.add({
name,
options: { codemode: false },
description,
input: Input,
description: fork
? description.replace(
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
"New child sessions start with fresh context by default, so include the context needed for the task.",
)
: description,
input: fork ? ForkInput : Input,
output: Output,
execute: (input, context) =>
execute: (input: typeof ForkInput.Type, context) =>
Effect.gen(function* () {
if (fork && input.fork !== undefined && input.sessionID !== undefined)
return yield* new ToolFailure({ message: "Cannot use fork with sessionID. Omit one of them." })
const parent = yield* sessions
.get(context.sessionID)
.pipe(
@@ -150,18 +167,40 @@ export const Plugin = {
const model = agent.model ?? parent.model
const child =
existing ??
(yield* sessions
.create({
parentID: context.sessionID,
title: input.description,
agent: Agent.ID.make(input.agent),
model,
})
.pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
),
))
(yield* (
fork && input.fork
? sessions.fork({
sessionID: context.sessionID,
parentID: context.sessionID,
boundary: { type: "before", messageID: context.messageID },
})
: sessions.create({
parentID: context.sessionID,
title: input.description,
agent: Agent.ID.make(input.agent),
model,
})
).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message:
fork && input.fork
? `Failed to create subagent: ${error.message}`
: `Parent session not found: ${context.sessionID}`,
error,
}),
),
))
if (fork && input.fork)
yield* sessions.rename({ sessionID: child.id, title: input.description }).pipe(
Effect.andThen(sessions.switchAgent({ sessionID: child.id, agent: agent.id })),
Effect.andThen(model ? sessions.switchModel({ sessionID: child.id, model }) : Effect.void),
Effect.mapError(
(error) => new ToolFailure({ message: `Failed to configure subagent: ${child.id}`, error }),
),
)
const background = input.background === true
yield* context.progress({ sessionID: child.id, status: "running" })
@@ -173,7 +212,12 @@ export const Plugin = {
sessionID: child.id,
text:
existing === undefined
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
? [
fork && input.fork
? "You are a forked subagent. Use the inherited history as context and perform only the task below."
: "You are a subagent spawned by another session.",
input.prompt,
].join("\n")
: input.prompt,
...(background && existing === undefined ? { resume: false } : {}),
})
@@ -230,8 +274,8 @@ export const Plugin = {
metadata: { sessionID: output.sessionID, status: output.status },
})),
),
}),
)
})
})
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
@@ -409,12 +409,14 @@ describe("ConfigNormalize", () => {
experimental: {
portable_shell_scanner: true,
subagent_depth: 0,
subagent_fork: true,
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
},
}).encoded.experimental,
).toEqual({
portable_shell_scanner: true,
subagent_depth: 0,
subagent_fork: true,
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
+65 -32
View File
@@ -578,40 +578,73 @@ describe("Session.create", () => {
}),
)
it.effect("replays a fork with stable projected identities", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location, title: "Parent" })
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "Second", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const original = (yield* session.context(forked.id)).map((message) => message.id)
const recorded = yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, forked.id))
.get()
.pipe(Effect.orDie)
if (!recorded) return yield* Effect.die(new Error("Fork event not found"))
for (const ownership of ["none", "source", "other"] as const) {
it.effect(`replays a fork with ${ownership} ownership and stable projected identities`, () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const source = yield* session.create({
location,
title: "Source",
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: Model.ID.make("source"), providerID: Provider.ID.make("test") }),
metadata: { source: true },
})
const parentID =
ownership === "none"
? undefined
: ownership === "source"
? source.id
: (yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/owner") }),
title: "Owner",
metadata: { owner: true },
})).id
yield* session.prompt({ sessionID: source.id, text: "First", resume: false })
yield* SessionInbox.promote(db, bus, source.id, "steer")
yield* session.synthetic({ sessionID: source.id, text: "Second", resume: false })
yield* SessionInbox.promote(db, bus, source.id, "steer")
const forked = yield* session.fork({ sessionID: source.id, boundary: { type: "through" }, parentID })
expect(forked.parentID).toBe(parentID)
expect(forked).toMatchObject({
title: "Source (fork #1)",
agent: source.agent,
model: source.model,
metadata: source.metadata,
location: source.location,
fork: { sessionID: source.id },
})
const original = (yield* session.context(forked.id)).map((message) => message.id)
const recorded = yield* db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, forked.id))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
expect(recorded.map((event) => event.type)).toEqual(
parentID ? ["session.created.1", "session.forked.2"] : ["session.forked.2"],
)
yield* bus.remove(forked.id)
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie)
yield* bus.replay({
id: recorded.id,
created: recorded.created,
aggregateID: recorded.aggregate_id,
seq: recorded.seq,
type: recorded.type,
data: recorded.data,
})
yield* bus.remove(forked.id)
yield* db.delete(SessionTable).where(eq(SessionTable.id, forked.id)).run().pipe(Effect.orDie)
yield* Effect.forEach(recorded, (event) =>
bus.replay({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}),
)
expect((yield* session.context(forked.id)).map((message) => message.id)).toEqual(original)
}),
)
expect((yield* session.context(forked.id)).map((message) => message.id)).toEqual(original)
expect(yield* session.get(forked.id)).toEqual(forked)
}),
)
}
it.effect("inherits instruction entries when forking", () =>
Effect.gen(function* () {
+287 -1
View File
@@ -30,11 +30,12 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Permission } from "@opencode-ai/core/permission"
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { testEffect } from "./lib/effect"
@@ -177,6 +178,291 @@ const withSubagent = (location: Location.Ref) =>
})
describe("SubagentTool", () => {
for (const enabled of [undefined, false, true]) {
productionIt.live(`gates the fork parameter with experimental.subagent_fork=${enabled}`, () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* Effect.promise(() =>
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: enabled } })),
)
const sessions = yield* Session.Service
const parent = yield* sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
})
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(locations.get(parent.location)))
const snapshot = yield* registry.snapshot()
const definition = snapshot.definitions.find((tool) => tool.name === SubagentTool.name)!
const context = yield* hooks.trigger("session", "context", {
sessionID: parent.id,
agent: toolIdentity.agent,
model: parentModel,
system: [],
messages: [],
tools: { subagent: { description: definition.description, input: { ...definition.inputSchema } } },
generation: {},
providerOptions: {},
})
expect(Object.keys(context.tools.subagent.input.properties ?? {})).toContain("sessionID")
expect(Object.keys(context.tools.subagent.input.properties ?? {}).includes("fork")).toBe(enabled === true)
expect(context.tools.subagent.input).toEqual(definition.inputSchema)
expect(Object.keys(definition.inputSchema.properties ?? {}).includes("fork")).toBe(enabled === true)
if (enabled === true) return
expect(Object.keys(definition.inputSchema.properties ?? {})).toEqual([
"agent",
"description",
"prompt",
"sessionID",
"background",
])
expect(definition.description).toBe(SubagentTool.description)
expect(definition.description).toContain(
"New child sessions start with fresh context, so include all relevant context and instructions when you don't pass a sessionID.",
)
expect(JSON.stringify(context.tools.subagent)).not.toMatch(/fork/i)
// An unknown field keeps the original schema's behavior; it cannot enable forking or advertise it.
const result = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-disabled-fork",
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review", fork: true },
},
})
expect(result.status).toBe("completed")
const childID = outputSessionID(result.metadata)
expect((yield* sessions.get(childID)).fork).toBeUndefined()
expect((yield* sessions.inbox(childID)).find((message) => message.type === "user")?.payload.text).toBe(
"You are a subagent spawned by another session.\nreview",
)
const continued = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-disabled-fork-continuation",
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "continue", fork: true, sessionID: childID },
},
})
expect(continued.status).toBe("completed")
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
}),
)
}
it.live("rejects fork together with sessionID without changing the child", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* Effect.promise(() =>
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: true } })),
)
const sessions = yield* Session.Service
const parent = yield* sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
})
const child = yield* sessions.create({ parentID: parent.id })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
for (const fork of [false, true]) {
const result = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: `call-conflicting-fork-${fork}`,
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review", fork, sessionID: child.id },
},
})
expect(result).toMatchObject({
status: "error",
error: { message: "Cannot use fork with sessionID. Omit one of them." },
})
}
expect(yield* sessions.get(child.id)).toEqual(child)
expect(yield* sessions.inbox(child.id)).toEqual([])
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
}),
)
completionIt.live("sends inherited history to a forked child and preserves it on continuation", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* Effect.promise(() =>
Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_fork: true } })),
)
const sessions = yield* Session.Service
const parent = yield* sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
agent: toolIdentity.agent,
model: parentModel,
metadata: { source: "fork-test" },
})
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
const hooks = yield* PluginHooks.Service.pipe(Effect.provide(locations.get(parent.location)))
const requests: PluginHooks.Domains["session"]["context"][] = []
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
requests.push(event)
}),
)
const bus = yield* Bus.Service
const { db } = yield* Database.Service
yield* sessions.prompt({ sessionID: parent.id, text: "Remember the project context", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const previous = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: parent.id,
assistantMessageID: previous,
agent: toolIdentity.agent,
model: parentModel,
})
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: parent.id,
assistantMessageID: previous,
finish: "tool-calls",
cost: Money.USD.zero,
tokens,
})
yield* sessions.updateMessage({
sessionID: parent.id,
messageID: previous,
content: [
{
type: "tool",
id: "call-parent-read",
name: "read",
time: { created: parent.time.created },
state: {
status: "completed",
input: { filePath: "README.md" },
content: [{ type: "text", text: "Inherited file contents" }],
},
},
],
})
const spawning = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: parent.id,
assistantMessageID: spawning,
agent: toolIdentity.agent,
model: parentModel,
})
yield* bus.publish(SessionEvent.Text.Started, { sessionID: parent.id, assistantMessageID: spawning, ordinal: 0 })
yield* bus.publish(SessionEvent.Text.Ended, {
sessionID: parent.id,
assistantMessageID: spawning,
ordinal: 0,
text: "Spawning response must not be inherited",
})
yield* sessions.prompt({ sessionID: parent.id, text: "Later parent message", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const result = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
messageID: spawning,
call: {
type: "tool-call",
id: "call-fork",
name: SubagentTool.name,
input: { agent: "reviewer", description: "fork review", prompt: "Review the file", fork: true },
},
})
expect(result.status).toBe("completed")
const child = yield* sessions.get(outputSessionID(result.metadata))
expect(child).toMatchObject({
parentID: parent.id,
title: "fork review",
agent: "reviewer",
model: childModel,
metadata: parent.metadata,
fork: { sessionID: parent.id, boundary: { type: "before", messageID: spawning } },
})
const request = requests.find((request) => request.sessionID === child.id)!
expect(request.agent).toBe(Agent.ID.make("reviewer"))
expect(request.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "user", content: [{ type: "text", text: "Remember the project context" }] }),
expect.objectContaining({
role: "assistant",
content: [expect.objectContaining({ type: "tool-call", id: "call-parent-read" })],
}),
expect.objectContaining({
role: "tool",
content: [
expect.objectContaining({
type: "tool-result",
result: { type: "text", value: "Inherited file contents" },
}),
],
}),
expect.objectContaining({
role: "user",
content: [
{
type: "text",
text: "You are a forked subagent. Use the inherited history as context and perform only the task below.\nReview the file",
},
],
}),
]),
)
expect(JSON.stringify(request.messages)).not.toContain("Spawning response must not be inherited")
expect(JSON.stringify(request.messages)).not.toContain("Later parent message")
expect((yield* sessions.context(child.id)).map((message) => message.id)).not.toContain(previous)
const continued = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-fork-continue",
name: SubagentTool.name,
input: { agent: "reviewer", description: "follow up", prompt: "Continue reviewing", sessionID: child.id },
},
})
expect(outputSessionID(continued.metadata)).toBe(child.id)
const latest = requests.findLast((request) => request.sessionID === child.id)!
expect(JSON.stringify(latest.messages)).toContain("Inherited file contents")
expect(JSON.stringify(latest.messages)).toContain("Continue reviewing")
expect(JSON.stringify(latest.messages)).not.toContain("Later parent message")
expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1)
for (const fork of [undefined, false]) {
const fresh = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: `call-fresh-${fork}`,
name: SubagentTool.name,
input: {
agent: "fallback",
description: "fresh",
prompt: "Start fresh",
...(fork === undefined ? {} : { fork }),
},
},
})
expect(fresh.status).toBe("completed")
const freshChild = yield* sessions.get(outputSessionID(fresh.metadata))
expect(freshChild.fork).toBeUndefined()
expect(freshChild.model).toMatchObject(parentModel)
const freshRequest = requests.find((request) => request.sessionID === freshChild.id)!
expect(JSON.stringify(freshRequest.messages)).not.toContain("Inherited file contents")
expect(JSON.stringify(freshRequest.messages)).toContain("Start fresh")
}
}),
)
completionIt.live("admits one durable completion across live delivery and restart replay", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -11,6 +11,9 @@ export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
subagent_fork: Schema.Boolean.pipe(optional).annotate({
description: "Enable the subagent fork parameter. Defaults to false.",
}),
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
description: "Ordered policies controlling access to configured resources",
}),
+11
View File
@@ -10,6 +10,17 @@ import { AbsolutePath } from "../src/schema.js"
import { WebSearch } from "../src/websearch.js"
describe("Config.Entry", () => {
test("keeps subagent forking opt-in", () => {
const decode = Schema.decodeUnknownSync(Config.Info)
const encode = Schema.encodeSync(Config.Info)
expect(encode(decode({ experimental: {} }))).toEqual({ experimental: {} })
for (const subagent_fork of [false, true]) {
const input = { experimental: { subagent_fork } }
expect(encode(decode(input))).toEqual(input)
}
expect(() => decode({ experimental: { subagent_fork: "true" } })).toThrow()
})
test("accepts directory-only worktree config and omits it when absent", () => {
const decode = Schema.decodeUnknownSync(Config.Info)
const input = { worktree: { directory: "../worktrees" } }