Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 26261f74c1 fix(core): preserve foreground subagent identity after crashes 2026-08-26 15:26:30 -04:00
12 changed files with 287 additions and 3 deletions
+14
View File
@@ -824,6 +824,20 @@ export type SessionLogOutput =
readonly state?: SessionMessage.ProviderState | undefined
}
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.delegated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly id: string
readonly childSessionID: Session.ID
}
}
| {
readonly id: Event.ID
readonly created: number
@@ -760,6 +760,16 @@ export type SessionToolInputEnded = {
data: { sessionID: string; assistantMessageID: string; id: string; text: string }
}
export type SessionToolDelegated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.tool.delegated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; id: string; childSessionID: string }
}
export type SessionRetryScheduled = {
id: string
created: number
@@ -2188,6 +2198,7 @@ export type SessionEventDurable =
| SessionToolInputStarted
| SessionToolInputEnded
| SessionToolCalled
| SessionToolDelegated
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
@@ -2251,6 +2262,7 @@ export type V2Event =
| SessionToolInputDelta
| SessionToolInputEnded
| SessionToolCalled
| SessionToolDelegated
| SessionToolProgress
| SessionToolSuccess
| SessionToolFailed
+10
View File
@@ -871,6 +871,16 @@ export function createData(config: CreateDataInput) {
match.state = { status: "running", input: event.data.input, metadata: {} }
})
return
case "session.tool.delegated":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status === "running")
match.state.metadata = { ...match.state.metadata, sessionID: event.data.childSessionID, status: "running" }
})
return
case "session.tool.progress":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
+62
View File
@@ -13,6 +13,68 @@ const session = (viewed: number): SessionInfo => ({
location: { directory: "/project" },
})
test("projects child bindings into only the owning running tool", () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const setup = createRoot((dispose) => ({
data: createData({
api: () => OpenCode.make({ baseUrl: "http://opencode.local" }),
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
const send = (event: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: event.type, details: event }))
const base = { sessionID: "ses_parent", assistantMessageID: "msg_parent", id: "call_child" }
const envelope = { id: "evt_fixture", created: 0, durable: { aggregateID: base.sessionID, seq: 0, version: 1 } }
try {
send({
...envelope,
type: "session.step.started",
data: {
sessionID: base.sessionID,
assistantMessageID: base.assistantMessageID,
agent: "build",
model: { id: "test", providerID: "test" },
},
})
send({ ...envelope, type: "session.tool.input.started", data: { ...base, name: "subagent" } })
send({ ...envelope, type: "session.tool.called", data: { ...base, input: {}, executed: false } })
const binding: OpenCodeEvent = {
...envelope,
type: "session.tool.delegated",
data: { ...base, childSessionID: "ses_child" },
}
send({ ...binding, data: { ...binding.data, sessionID: "ses_other" } })
send({ ...binding, data: { ...binding.data, assistantMessageID: "msg_other" } })
send({ ...binding, data: { ...binding.data, id: "call_other" } })
const message = () => setup.data.session.message.get(base.sessionID, base.assistantMessageID)
expect(message()).toMatchObject({ content: [{ state: { status: "running", metadata: {} } }] })
expect(JSON.stringify(message())).not.toContain("ses_child")
send(binding)
expect(message()).toMatchObject({
content: [{ state: { status: "running", metadata: { sessionID: "ses_child", status: "running" } } }],
})
send({
...envelope,
type: "session.tool.success",
data: { ...base, content: [{ type: "text", text: "Finished" }], executed: false },
})
send(binding)
expect(message()).toMatchObject({
content: [{ state: { status: "completed", content: [{ type: "text", text: "Finished" }] } }],
})
expect(JSON.stringify(message())).not.toContain("ses_child")
} finally {
setup.dispose()
}
})
test("revalidates after an event overtakes an active session read", async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
@@ -322,6 +322,12 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}
})
},
"session.tool.delegated": (event) =>
updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.id)
if (match?.state.status === "running")
match.state.metadata = { ...match.state.metadata, sessionID: event.data.childSessionID, status: "running" }
}),
// Terminal tool events are self-contained; projection is a direct copy and
// never reaches into ephemeral progress history.
"session.tool.success": (event) => {
+1
View File
@@ -679,6 +679,7 @@ const layer = Layer.effectDiscard(
yield* bus.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
yield* bus.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
yield* bus.project(SessionEvent.Tool.Called, (event) => run(db, event))
yield* bus.project(SessionEvent.Tool.Delegated, (event) => run(db, event))
yield* bus.project(SessionEvent.Tool.Success, (event) => run(db, event))
yield* bus.project(SessionEvent.Tool.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
@@ -4,10 +4,12 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { Agent } from "../../agent.js"
import { Bus } from "../../bus.js"
import { Config } from "../../config.js"
import { PluginRuntime } from "../../plugin/runtime.js"
import { Permission } from "../../permission.js"
import { SessionSchema } from "../../session/schema.js"
import { SessionEvent } from "../../session/event.js"
export const name = "subagent"
@@ -54,6 +56,7 @@ export const Plugin = {
id: "opencode.tool.subagent",
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service
const bus = yield* Bus.Service
const agents = yield* Agent.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
@@ -213,6 +216,12 @@ export const Plugin = {
))
const background = input.background === true
yield* bus.publish(SessionEvent.Tool.Delegated, {
sessionID: context.sessionID,
assistantMessageID: context.messageID,
id: context.id,
childSessionID: child.id,
})
yield* context.progress({ sessionID: child.id, status: "running" })
// Standard prompt admission outside the job: Job.start joining a running child skips
@@ -61,6 +61,76 @@ const assistantRow = (
}
describe("SessionProjector", () => {
it.effect("replays child bindings without child history and leaves other or settled tools unchanged", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
yield* database.db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* bus.publish(SessionEvent.Created, {
sessionID,
projectID: Project.ID.global,
location: { directory: AbsolutePath.make("/project") },
slug: "test",
version: "test",
})
const first = SessionMessage.ID.create()
const second = SessionMessage.ID.create()
for (const assistantMessageID of [first, second]) {
yield* bus.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, agent: build, model })
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
id: "call-child",
name: "subagent",
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
id: "call-child",
input: {},
executed: false,
})
}
const binding = {
id: Event.ID.create(),
type: Bus.versionedType(SessionEvent.Tool.Delegated.type, 1),
aggregateID: sessionID,
seq: (yield* Bus.latestSequence(database.db, sessionID)) + 1,
data: { sessionID, assistantMessageID: first, id: "call-child", childSessionID: "ses_child" },
}
yield* bus.replay(binding)
yield* bus.replay(binding)
const message = (id: SessionMessage.ID) =>
database.db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get()
expect((yield* message(first))?.data).toMatchObject({
content: [{ state: { status: "running", metadata: { sessionID: "ses_child", status: "running" } } }],
})
expect((yield* message(second))?.data).toMatchObject({
content: [{ state: { status: "running", metadata: {} } }],
})
expect(JSON.stringify((yield* message(second))?.data)).not.toContain("ses_child")
yield* bus.publish(SessionEvent.Tool.Success, {
sessionID,
assistantMessageID: first,
id: "call-child",
content: [{ type: "text", text: "Finished" }],
executed: false,
})
yield* bus.publish(SessionEvent.Tool.Delegated, {
...binding.data,
childSessionID: Session.ID.make("ses_other_child"),
})
expect((yield* message(first))?.data).toMatchObject({
content: [{ state: { status: "completed", content: [{ type: "text", text: "Finished" }] } }],
})
expect(JSON.stringify((yield* message(first))?.data)).not.toContain("ses_other_child")
expect(yield* database.db.select().from(SessionTable).all()).toHaveLength(1)
}),
)
it.effect("does not settle a pending manual compaction on an auto failure", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
+8 -2
View File
@@ -3673,13 +3673,19 @@ describe("SessionRunnerLLM", () => {
input: { agent: "general" },
executed: false,
})
yield* bus.publish(SessionEvent.Tool.Delegated, {
sessionID,
assistantMessageID,
id: "call-interrupted-subagent",
childSessionID: Session.ID.make("ses_existing_child"),
})
yield* database.db
.update(SessionMessageTable)
.set({
data: sql`json_set(
${SessionMessageTable.data},
'$.content[0].state.metadata',
json('{"sessionID":"ses_existing_child","status":"running","internal":"private"}')
'$.content[0].state.metadata.internal',
'private'
)`,
})
.where(eq(SessionMessageTable.id, assistantMessageID))
+79 -1
View File
@@ -102,7 +102,7 @@ const subagentPluginSupervisor = makeLocationNode({
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
deps: [Agent.node, Bus.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
const nodes = LayerNode.group([
@@ -328,6 +328,84 @@ describe("SubagentTool", () => {
),
)
it.live("persists new and continued child bindings before prompt admission", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const bus = yield* Bus.Service
const parent = yield* sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }),
model: parentModel,
})
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
const execute = (existing?: Session.ID) =>
Effect.gen(function* () {
const assistantMessageID = SessionMessage.ID.create()
const call = {
type: "tool-call" as const,
id: existing ? "call-continued-subagent" : "call-new-subagent",
name: SubagentTool.name,
input: {
agent: "reviewer",
description: "review",
prompt: "review this",
...(existing ? { sessionID: existing } : {}),
},
}
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: parent.id,
assistantMessageID,
agent: toolIdentity.agent,
model: parentModel,
})
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID: parent.id,
assistantMessageID,
id: call.id,
name: call.name,
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID: parent.id,
assistantMessageID,
id: call.id,
input: call.input,
executed: false,
})
const settled = yield* executeTool(registry, {
sessionID: parent.id,
agent: toolIdentity.agent,
messageID: assistantMessageID,
call,
progress: (update) =>
Effect.gen(function* () {
const childID = outputSessionID(update)
expect((yield* sessions.get(childID)).parentID).toBe(parent.id)
expect(yield* sessions.inbox(childID)).toHaveLength(existing ? 1 : 0)
expect(
yield* sessions.message({ sessionID: parent.id, messageID: assistantMessageID }),
).toMatchObject({
content: [
{ type: "tool", id: call.id, state: { status: "running", metadata: { sessionID: childID } } },
],
})
}).pipe(Effect.orDie),
})
expect(settled.status).toBe("completed")
return outputSessionID(settled.metadata)
})
const childID = yield* execute()
expect(yield* execute(childID)).toBe(childID)
}),
),
),
)
it.live("continues an existing child session", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+12
View File
@@ -491,6 +491,17 @@ export namespace Tool {
})
export type Called = typeof Called.Type
/** Binds a tool call to its child Session before admitting work to that child. */
export const Delegated = Event.durable({
type: "session.tool.delegated",
...options,
schema: {
...ToolBase,
childSessionID: SessionID,
},
})
export type Delegated = typeof Delegated.Type
/** Live replacement metadata for a running tool. */
export const Progress = Event.ephemeral({
type: "session.tool.progress",
@@ -652,6 +663,7 @@ export const Definitions = Event.inventory(
Tool.Input.Delta,
Tool.Input.Ended,
Tool.Called,
Tool.Delegated,
Tool.Progress,
Tool.Success,
Tool.Failed,
@@ -143,6 +143,7 @@ describe("public event manifest", () => {
"session.tool.input.started.1",
"session.tool.input.ended.1",
"session.tool.called.1",
"session.tool.delegated.1",
"session.tool.success.2",
"session.tool.failed.2",
"session.reasoning.started.1",
@@ -170,6 +171,9 @@ describe("public event manifest", () => {
expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral")
expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral")
expect(SessionEvent.Tool.Progress.durability).toBe("ephemeral")
expect(SessionEvent.Tool.Delegated.durability).toBe("durable")
expect(EventManifest.Durable.get("session.tool.delegated.1")).toBe(SessionEvent.Tool.Delegated)
expect(EventManifest.Server.get("session.tool.delegated")).toBe(SessionEvent.Tool.Delegated)
expect(EventManifest.Server.get("session.tool.progress")).toBe(SessionEvent.Tool.Progress)
expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false)
expect(EventManifest.ServerDefinitions).toContain(SessionEvent.UsageUpdated)