mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-25 01:57:38 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6051a67bc2 |
@@ -226,6 +226,20 @@ export type SessionForkInput = { readonly sessionID: Session.ID; readonly before
|
||||
export type SessionForkOutput = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: SessionForkInput) => Effect.Effect<SessionForkOutput, E>
|
||||
|
||||
export type SessionSubagentInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly text: string
|
||||
readonly description: string
|
||||
readonly agent?: Agent.ID | undefined
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly fork?: boolean | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type SessionSubagentOutput = Session.Info
|
||||
export type SessionSubagentOperation<E = never> = (
|
||||
input: SessionSubagentInput,
|
||||
) => Effect.Effect<SessionSubagentOutput, E>
|
||||
|
||||
export type SessionSwitchAgentInput = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type SessionSwitchAgentOutput = void
|
||||
export type SessionSwitchAgentOperation<E = never> = (
|
||||
@@ -589,6 +603,7 @@ export type SessionLogOutput =
|
||||
readonly sessionID: Session.ID
|
||||
readonly parentID: Session.ID
|
||||
readonly boundary: Session.ForkBoundary
|
||||
readonly child?: boolean | undefined
|
||||
readonly instructions?:
|
||||
| { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
|
||||
| undefined
|
||||
@@ -1409,6 +1424,7 @@ export interface SessionApi<E = never> {
|
||||
readonly get: SessionGetOperation<E>
|
||||
readonly remove: SessionRemoveOperation<E>
|
||||
readonly fork: SessionForkOperation<E>
|
||||
readonly subagent: SessionSubagentOperation<E>
|
||||
readonly switchAgent: SessionSwitchAgentOperation<E>
|
||||
readonly switchModel: SessionSwitchModelOperation<E>
|
||||
readonly update: SessionUpdateOperation<E>
|
||||
|
||||
@@ -36,6 +36,8 @@ import type {
|
||||
SessionRemoveOutput,
|
||||
SessionForkInput,
|
||||
SessionForkOutput,
|
||||
SessionSubagentInput,
|
||||
SessionSubagentOutput,
|
||||
SessionSwitchAgentInput,
|
||||
SessionSwitchAgentOutput,
|
||||
SessionSwitchModelInput,
|
||||
@@ -435,6 +437,24 @@ const EndpointSessionFork = (raw: RawClient["server.session"]) => (input: Sessio
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionSubagent = (raw: RawClient["server.session"]) => (input: SessionSubagentInput) =>
|
||||
preserveEffect<SessionSubagentOutput>()(
|
||||
raw["session.subagent"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
text: input["text"],
|
||||
description: input["description"],
|
||||
agent: input["agent"],
|
||||
model: input["model"],
|
||||
fork: input["fork"],
|
||||
resume: input["resume"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionSwitchAgent = (raw: RawClient["server.session"]) => (input: SessionSwitchAgentInput) =>
|
||||
preserveEffect<SessionSwitchAgentOutput>()(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
@@ -747,6 +767,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
get: EndpointSessionGet(raw),
|
||||
remove: EndpointSessionRemove(raw),
|
||||
fork: EndpointSessionFork(raw),
|
||||
subagent: EndpointSessionSubagent(raw),
|
||||
switchAgent: EndpointSessionSwitchAgent(raw),
|
||||
switchModel: EndpointSessionSwitchModel(raw),
|
||||
update: EndpointSessionUpdate(raw),
|
||||
|
||||
@@ -30,6 +30,8 @@ import type {
|
||||
SessionRemoveOutput,
|
||||
SessionForkInput,
|
||||
SessionForkOutput,
|
||||
SessionSubagentInput,
|
||||
SessionSubagentOutput,
|
||||
SessionSwitchAgentInput,
|
||||
SessionSwitchAgentOutput,
|
||||
SessionSwitchModelInput,
|
||||
@@ -637,6 +639,25 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
subagent: (input: SessionSubagentInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionSubagentOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/subagent`,
|
||||
body: {
|
||||
text: input["text"],
|
||||
description: input["description"],
|
||||
agent: input["agent"],
|
||||
model: input["model"],
|
||||
fork: input["fork"],
|
||||
resume: input["resume"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
switchAgent: (input: SessionSwitchAgentInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionSwitchAgentOutput>(
|
||||
{
|
||||
|
||||
@@ -1811,6 +1811,7 @@ export type SessionForked = {
|
||||
sessionID: string
|
||||
parentID: string
|
||||
boundary: SessionForkBoundary
|
||||
child?: boolean
|
||||
instructions?: { [x: string]: string }
|
||||
instructionEntries?: InstructionEntrySnapshot
|
||||
}
|
||||
@@ -3938,6 +3939,60 @@ export type SessionForkInput = {
|
||||
|
||||
export type SessionForkOutput = { data: SessionInfo }["data"]
|
||||
|
||||
export type SessionSubagentInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly text: {
|
||||
readonly text: string
|
||||
readonly description: string
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly fork?: boolean | null
|
||||
readonly resume?: boolean | null
|
||||
}["text"]
|
||||
readonly description: {
|
||||
readonly text: string
|
||||
readonly description: string
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly fork?: boolean | null
|
||||
readonly resume?: boolean | null
|
||||
}["description"]
|
||||
readonly agent?: {
|
||||
readonly text: string
|
||||
readonly description: string
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly fork?: boolean | null
|
||||
readonly resume?: boolean | null
|
||||
}["agent"]
|
||||
readonly model?: {
|
||||
readonly text: string
|
||||
readonly description: string
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly fork?: boolean | null
|
||||
readonly resume?: boolean | null
|
||||
}["model"]
|
||||
readonly fork?: {
|
||||
readonly text: string
|
||||
readonly description: string
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly fork?: boolean | null
|
||||
readonly resume?: boolean | null
|
||||
}["fork"]
|
||||
readonly resume?: {
|
||||
readonly text: string
|
||||
readonly description: string
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly fork?: boolean | null
|
||||
readonly resume?: boolean | null
|
||||
}["resume"]
|
||||
}
|
||||
|
||||
export type SessionSubagentOutput = { data: SessionInfo }["data"]
|
||||
|
||||
export type SessionSwitchAgentInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly agent: { readonly agent: string }["agent"]
|
||||
|
||||
@@ -23,6 +23,8 @@ const Background = Schema.Struct({
|
||||
childSessionID: SessionSchema.ID,
|
||||
agent: Schema.String,
|
||||
description: Schema.String,
|
||||
/** False admits the completion notice without resuming the parent. */
|
||||
resume: Schema.optionalKey(Schema.Boolean),
|
||||
}),
|
||||
]),
|
||||
status: Schema.Literals(["running", "completed", "error", "cancelled"]),
|
||||
|
||||
@@ -542,6 +542,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
}),
|
||||
move: sessions.move,
|
||||
synthetic: sessions.synthetic,
|
||||
subagent: sessions.subagent,
|
||||
interrupt: (input) =>
|
||||
sessions
|
||||
.interrupt(input.sessionID, { resume: input.resume })
|
||||
|
||||
@@ -26,6 +26,7 @@ import { SessionRunner } from "./session/runner/index.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
import {
|
||||
AgentNotFoundError,
|
||||
AttachmentError,
|
||||
BusyError,
|
||||
CompactionConflictError,
|
||||
@@ -45,6 +46,8 @@ import { SessionInbox } from "./session/inbox.js"
|
||||
import { InstructionState } from "./session/instruction-state.js"
|
||||
import { SessionGenerate } from "./session/generate.js"
|
||||
import { SessionCommand } from "./session/command.js"
|
||||
import { SessionSubagent } from "./session/subagent.js"
|
||||
import { SubagentJob } from "./session/subagent-job.js"
|
||||
import {
|
||||
SessionMove,
|
||||
DestinationNotFoundError,
|
||||
@@ -95,9 +98,12 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
|
||||
type ForkInput = {
|
||||
sessionID: SessionSchema.ID
|
||||
before?: SessionMessage.ID
|
||||
/** Makes the fork a child of the source session instead of a top-level session. */
|
||||
child?: boolean
|
||||
}
|
||||
|
||||
export {
|
||||
AgentNotFoundError,
|
||||
AttachmentError,
|
||||
BusyError,
|
||||
CompactionConflictError,
|
||||
@@ -187,6 +193,10 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
prompt: string
|
||||
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
|
||||
/** Starts a background subagent in a child Session and delivers its outcome to the parent when it settles. */
|
||||
readonly subagent: (
|
||||
input: SessionSubagent.Input,
|
||||
) => Effect.Effect<SessionSchema.Info, NotFoundError | AgentNotFoundError>
|
||||
readonly command: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
command: string
|
||||
@@ -341,6 +351,7 @@ const layer = Layer.effect(
|
||||
sessionID,
|
||||
parentID: parent.id,
|
||||
boundary: { type: input.before ? "before" : "through", messageID: boundary.id },
|
||||
...(input.child ? { child: true } : {}),
|
||||
...inherited,
|
||||
})
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
@@ -410,6 +421,8 @@ const layer = Layer.effect(
|
||||
Effect.provideService(LLMClient.Service, llm),
|
||||
)
|
||||
}),
|
||||
subagent: (input) =>
|
||||
SessionSubagent.spawn(result, subagents, input).pipe(Effect.provideService(Instance.Service, instances)),
|
||||
command: Effect.fn("Session.command")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
return yield* SessionCommand.execute({ ...input, session }).pipe(
|
||||
@@ -454,6 +467,7 @@ const layer = Layer.effect(
|
||||
commit: (sessionID) => sessions.forSession(sessionID).revert.commit(),
|
||||
},
|
||||
})
|
||||
const subagents = yield* SubagentJob.make.pipe(Effect.provideService(Service, result))
|
||||
|
||||
return result
|
||||
}),
|
||||
|
||||
@@ -148,7 +148,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: event.data.sessionID,
|
||||
parent_id: null,
|
||||
parent_id: event.data.child ? event.data.parentID : null,
|
||||
fork_session_id: event.data.parentID,
|
||||
fork_boundary: event.data.boundary,
|
||||
project_id: parent.project_id,
|
||||
|
||||
@@ -36,7 +36,7 @@ export const deliver = Effect.fnUntraced(function* (
|
||||
yield* sessions.synthetic({
|
||||
...(input.notificationID ? { id: input.notificationID } : {}),
|
||||
sessionID: recovery.parentSessionID,
|
||||
...(input.resume === false ? { resume: false } : {}),
|
||||
...((input.resume ?? recovery.resume) === false ? { resume: false } : {}),
|
||||
description: recovery.description,
|
||||
text: `<subagent sessionID="${recovery.childSessionID}" state="${input.status}" description="${recovery.description}">\n${text}\n</subagent>`,
|
||||
metadata: { source: "subagent", childID: recovery.childSessionID, agent: recovery.agent, state: input.status },
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SubagentCompletion } from "./subagent-completion.js"
|
||||
|
||||
type Recovery = Extract<Job.Recovery, { kind: "subagent" }>
|
||||
|
||||
interface Runner {
|
||||
export interface Runner {
|
||||
start: (recovery: Recovery) => Effect.Effect<Job.Info>
|
||||
background: (recovery: Recovery) => Effect.Effect<void>
|
||||
notify: (recovery: Recovery, startedAt: number) => Effect.Effect<void>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
export * as SessionSubagent from "./subagent.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Instance } from "../instance/service.js"
|
||||
import type { Model } from "../model.js"
|
||||
import { Plugin } from "../plugin/service.js"
|
||||
import type { Session } from "../session.js"
|
||||
import { AgentNotFoundError } from "./error.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
import type { SubagentJob } from "./subagent-job.js"
|
||||
|
||||
const preamble = "You are a subagent spawned by another session."
|
||||
|
||||
export type Input = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly text: string
|
||||
readonly description: string
|
||||
readonly agent?: Agent.ID
|
||||
readonly model?: Model.Ref
|
||||
/** Copies the parent's settled history into the child instead of starting with fresh context. */
|
||||
readonly fork?: boolean
|
||||
/** False admits the completion notice to the parent without resuming it. */
|
||||
readonly resume?: boolean
|
||||
}
|
||||
|
||||
/** Starts a background child Session whose outcome is delivered to the parent when it settles. */
|
||||
export const spawn = Effect.fn("SessionSubagent.spawn")(function* (
|
||||
sessions: Session.Interface,
|
||||
subagents: SubagentJob.Runner,
|
||||
input: Input,
|
||||
) {
|
||||
const instances = yield* Instance.Service
|
||||
const parent = yield* sessions.get(input.sessionID)
|
||||
const selected = yield* Plugin.awaitActivation.pipe(
|
||||
Effect.andThen(Agent.Service),
|
||||
Effect.flatMap((agents) => agents.select(input.agent ?? parent.agent)),
|
||||
instances.provide(parent),
|
||||
)
|
||||
if (input.agent !== undefined && selected.info === undefined)
|
||||
return yield* new AgentNotFoundError({ sessionID: parent.id, agent: input.agent })
|
||||
|
||||
const create = sessions.create({
|
||||
parentID: parent.id,
|
||||
title: input.description,
|
||||
agent: selected.id,
|
||||
model: input.model ?? selected.info?.model ?? parent.model,
|
||||
})
|
||||
const child = input.fork
|
||||
? yield* sessions.fork({ sessionID: parent.id, child: true }).pipe(
|
||||
Effect.tap((forked) => {
|
||||
// A fork inherits the parent's agent and model; an explicit model wins over the switched agent's model.
|
||||
const switched = forked.agent !== selected.id
|
||||
const model = input.model ?? (switched ? selected.info?.model : undefined)
|
||||
return Effect.all(
|
||||
[
|
||||
sessions.rename({ sessionID: forked.id, title: input.description }),
|
||||
switched ? sessions.switchAgent({ sessionID: forked.id, agent: selected.id }) : Effect.void,
|
||||
model === undefined ? Effect.void : sessions.switchModel({ sessionID: forked.id, model }),
|
||||
],
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
Effect.catchTag("Session.ForkEmptyError", () => create),
|
||||
// Only a `before` boundary can be missing.
|
||||
Effect.catchTag("Session.MessageNotFoundError", Effect.die),
|
||||
)
|
||||
: yield* create
|
||||
|
||||
// A text-only prompt without an explicit ID cannot fail admission on the Session just created.
|
||||
yield* sessions
|
||||
.prompt({ sessionID: child.id, text: [preamble, input.text].join("\n"), resume: false })
|
||||
.pipe(Effect.orDie)
|
||||
const recovery = {
|
||||
kind: "subagent" as const,
|
||||
parentSessionID: parent.id,
|
||||
childSessionID: child.id,
|
||||
agent: selected.id,
|
||||
description: input.description,
|
||||
...(input.resume === false ? { resume: false } : {}),
|
||||
}
|
||||
yield* subagents.start(recovery)
|
||||
yield* subagents.background(recovery)
|
||||
return yield* sessions.get(child.id)
|
||||
})
|
||||
@@ -173,6 +173,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
update: overrides.session?.update ?? (() => Effect.die("unused session.update")),
|
||||
move: overrides.session?.move ?? (() => Effect.die("unused session.move")),
|
||||
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
|
||||
subagent: overrides.session?.subagent ?? (() => Effect.die("unused session.subagent")),
|
||||
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
|
||||
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
|
||||
context: overrides.session?.context ?? (() => Effect.die("unused session.context")),
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { LanguageModel } from "@opencode/ai"
|
||||
import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
|
||||
import { TestLLM } from "@opencode/ai/testing"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode/core/effect/app-node-platform"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const llmLayer = TestLLM.testLayer({ fallback: TestLLM.text("Docs updated", "docs") })
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
llmLayer,
|
||||
AppNodeBuilder.build(LayerNode.group([Session.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
LayerNodePlatform.llmClient.replace(llmLayer),
|
||||
SessionRunnerModel.node.replace(
|
||||
Layer.succeed(SessionRunnerModel.Service, {
|
||||
resolve: (session) =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: session.model?.id ?? "parent", provider: "test", route: OpenAIChat.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
},
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
|
||||
|
||||
describe("session subagents", () => {
|
||||
it.live("forks settled history into a child and admits its outcome without resuming the parent", () =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* project()
|
||||
const sessions = yield* Session.Service
|
||||
const llm = yield* TestLLM.Test
|
||||
yield* sessions.prompt({ sessionID: parent.id, text: "Earlier question" })
|
||||
yield* sessions.wait(parent.id)
|
||||
const gate = yield* llm.gate()
|
||||
|
||||
// This must return while the child's model is still blocked.
|
||||
const child = yield* sessions.subagent({
|
||||
sessionID: parent.id,
|
||||
text: "Update the docs",
|
||||
description: "Update docs",
|
||||
fork: true,
|
||||
resume: false,
|
||||
})
|
||||
yield* gate.started
|
||||
expect(child).toMatchObject({
|
||||
parentID: parent.id,
|
||||
fork: { sessionID: parent.id },
|
||||
title: "Update docs",
|
||||
agent: "build",
|
||||
model: parentModel,
|
||||
})
|
||||
expect((yield* sessions.list({ parentID: parent.id })).data.map((session) => session.id)).toEqual([child.id])
|
||||
expect(
|
||||
(yield* sessions.context(child.id)).flatMap((message) => (message.type === "user" ? [message.text] : [])),
|
||||
).toEqual(["Earlier question", "You are a subagent spawned by another session.\nUpdate the docs"])
|
||||
|
||||
yield* gate.release
|
||||
const notice = yield* sessions.log({ sessionID: parent.id, follow: true }).pipe(
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
!Bus.isSynced(event) && event.type === "session.inbox.enqueued" && event.data.item.type === "synthetic",
|
||||
),
|
||||
Stream.runHead,
|
||||
)
|
||||
expect(notice).toMatchObject({
|
||||
_tag: "Some",
|
||||
value: { data: { item: { metadata: { source: "subagent", childID: child.id, state: "completed" } } } },
|
||||
})
|
||||
expect(yield* sessions.inbox(parent.id)).toMatchObject([{ type: "synthetic" }])
|
||||
expect((yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")).toEqual([])
|
||||
expect(yield* llm.requests()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates a fresh child with the requested agent when there is no history to fork", () =>
|
||||
Effect.gen(function* () {
|
||||
const parent = yield* project()
|
||||
const sessions = yield* Session.Service
|
||||
const llm = yield* TestLLM.Test
|
||||
const gate = yield* llm.gate()
|
||||
|
||||
const child = yield* sessions.subagent({
|
||||
sessionID: parent.id,
|
||||
text: "Review the changes",
|
||||
description: "Review changes",
|
||||
agent: Agent.ID.make("reviewer"),
|
||||
fork: true,
|
||||
})
|
||||
yield* gate.started
|
||||
expect(child).toMatchObject({ parentID: parent.id, agent: "reviewer", model: { id: "child" } })
|
||||
expect(child.fork).toBeUndefined()
|
||||
yield* gate.release
|
||||
|
||||
expect(
|
||||
yield* sessions
|
||||
.subagent({ sessionID: parent.id, text: "x", description: "x", agent: Agent.ID.make("missing") })
|
||||
.pipe(Effect.flip),
|
||||
).toBeInstanceOf(Session.AgentNotFoundError)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function project() {
|
||||
return Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({ agents: { reviewer: { mode: "subagent", model: "test/child" } } }),
|
||||
),
|
||||
)
|
||||
const sessions = yield* Session.Service
|
||||
return yield* sessions.create({
|
||||
location: { directory: AbsolutePath.make(tmp.path) },
|
||||
title: "Parent session",
|
||||
agent: Agent.ID.make("build"),
|
||||
model: parentModel,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -160,6 +160,7 @@ export type SessionDomain = Pick<
|
||||
| "generate"
|
||||
| "command"
|
||||
| "synthetic"
|
||||
| "subagent"
|
||||
| "interrupt"
|
||||
| "update"
|
||||
| "move"
|
||||
|
||||
@@ -586,6 +586,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
generate: adaptApiMethod(SessionEndpoints["session.generate"], host.session.generate),
|
||||
command: adaptApiMethod(SessionEndpoints["session.command"], host.session.command),
|
||||
synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic),
|
||||
subagent: adaptApiMethod(SessionEndpoints["session.subagent"], host.session.subagent),
|
||||
interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
|
||||
update: adaptApiMethod(SessionEndpoints["session.update"], host.session.update),
|
||||
move: adaptApiMethod(SessionEndpoints["session.move"], host.session.move),
|
||||
|
||||
@@ -160,6 +160,7 @@ export type SessionDomain = Pick<
|
||||
| "generate"
|
||||
| "command"
|
||||
| "synthetic"
|
||||
| "subagent"
|
||||
| "interrupt"
|
||||
| "update"
|
||||
| "move"
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Event } from "@opencode/schema/event"
|
||||
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import {
|
||||
AgentNotFoundError,
|
||||
ConflictError,
|
||||
CommandExecutionError,
|
||||
CommandNotFoundError,
|
||||
@@ -322,6 +323,30 @@ export const makeSessionGroup = <
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.subagent", "/api/session/:sessionID/subagent", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({
|
||||
text: Schema.String,
|
||||
description: Schema.String,
|
||||
agent: Agent.ID.pipe(Schema.optional),
|
||||
model: Model.Ref.pipe(Schema.optional),
|
||||
fork: Schema.Boolean.pipe(Schema.optional),
|
||||
resume: Schema.Boolean.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({ data: PublicSessionInfo }),
|
||||
error: [SessionNotFoundError, AgentNotFoundError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.subagent",
|
||||
summary: "Start subagent",
|
||||
description:
|
||||
"Start a background subagent in a child session and deliver its outcome to this session when it settles. Set fork to copy this session's settled history into the child. Set resume to false to admit the outcome without resuming this session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", {
|
||||
params: { sessionID: Session.ID },
|
||||
|
||||
@@ -197,6 +197,8 @@ export const Forked = Event.durable({
|
||||
...Base,
|
||||
parentID: SessionID,
|
||||
boundary: SessionFork.Boundary,
|
||||
/** The fork is a child of its source session, such as a forked subagent. */
|
||||
child: Schema.Boolean.pipe(optional),
|
||||
instructions: Instruction.Values.pipe(optional),
|
||||
instructionEntries: InstructionEntry.Snapshot.pipe(optional),
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { SessionsCursor } from "@opencode/protocol/groups/session"
|
||||
import {
|
||||
AgentNotFoundError,
|
||||
ConflictError,
|
||||
CommandExecutionError,
|
||||
CommandNotFoundError,
|
||||
@@ -234,6 +235,20 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.subagent",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session.subagent({ sessionID: ctx.params.sessionID, ...ctx.payload }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", missingSession),
|
||||
Effect.catchTag(
|
||||
"Session.AgentNotFoundError",
|
||||
(error) => new AgentNotFoundError({ agentID: error.agent, message: error.message }),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.switchAgent",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -766,6 +766,13 @@ const command = await ctx.session.command({ sessionID, command: "review", argume
|
||||
const synthetic = await ctx.session.synthetic({ sessionID, text: "Deployment completed" })
|
||||
```
|
||||
|
||||
Start a background subagent in a child session. Set `fork` to copy the session's settled history into the child. The
|
||||
subagent's outcome is added to the parent when it settles; set `resume` to `false` to add it without resuming the parent.
|
||||
|
||||
```ts
|
||||
const child = await ctx.session.subagent({ sessionID, text: "Update the docs", description: "Update docs", fork: true })
|
||||
```
|
||||
|
||||
Rename, interrupt, or wait for a session.
|
||||
|
||||
```ts
|
||||
@@ -790,6 +797,7 @@ interface SessionContext {
|
||||
generate(input: SessionGenerateInput, requestOptions?: RequestOptions): Promise<{ text: string }>
|
||||
command(input: SessionCommandInput, requestOptions?: RequestOptions): Promise<SessionInboxUser>
|
||||
synthetic(input: SessionSyntheticInput, requestOptions?: RequestOptions): Promise<SessionInboxSynthetic>
|
||||
subagent(input: SessionSubagentInput, requestOptions?: RequestOptions): Promise<SessionInfo>
|
||||
interrupt(input: SessionInterruptInput, requestOptions?: RequestOptions): Promise<void>
|
||||
rename(input: SessionRenameInput, requestOptions?: RequestOptions): Promise<void>
|
||||
wait(input: SessionWaitInput, requestOptions?: RequestOptions): Promise<void>
|
||||
|
||||
Reference in New Issue
Block a user