mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-26 02:14:43 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26261f74c1 |
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user