Compare commits
5 commits
dev
...
feat/core-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc02446ff4 | ||
|
|
73be8f7658 | ||
|
|
70ab2e2aea | ||
|
|
af22efb4e4 | ||
|
|
74a269af7d |
7 changed files with 378 additions and 25 deletions
|
|
@ -2,6 +2,7 @@ export * as OpenCode from "./opencode"
|
||||||
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { Catalog } from "../catalog"
|
import { Catalog } from "../catalog"
|
||||||
|
import { AgentV2 } from "../agent"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { LocationServiceMap } from "../location-layer"
|
import { LocationServiceMap } from "../location-layer"
|
||||||
|
|
@ -12,12 +13,13 @@ import * as SessionExecutionLocal from "../session/execution/local"
|
||||||
import { SessionProjector } from "../session/projector"
|
import { SessionProjector } from "../session/projector"
|
||||||
import { SessionStore } from "../session/store"
|
import { SessionStore } from "../session/store"
|
||||||
import { ApplicationTools } from "../tool/application-tools"
|
import { ApplicationTools } from "../tool/application-tools"
|
||||||
|
import { TaskTool } from "../tool/task"
|
||||||
import { Session } from "./session"
|
import { Session } from "./session"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly sessions: Session.Interface
|
readonly session: Session.Interface
|
||||||
readonly tools: Tool.Interface
|
readonly tool: Tool.Interface
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Intentional public native API for Effect applications embedding OpenCode. */
|
/** Intentional public native API for Effect applications embedding OpenCode. */
|
||||||
|
|
@ -77,7 +79,7 @@ const SessionsLayer = Layer.merge(
|
||||||
Layer.orDie,
|
Layer.orDie,
|
||||||
),
|
),
|
||||||
SessionModelValidationLayer,
|
SessionModelValidationLayer,
|
||||||
).pipe(Layer.provide(LocationServicesLayer))
|
).pipe(Layer.provideMerge(LocationServicesLayer))
|
||||||
const ApplicationToolsLayer = ApplicationTools.layer
|
const ApplicationToolsLayer = ApplicationTools.layer
|
||||||
|
|
||||||
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
|
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
|
||||||
|
|
@ -85,14 +87,24 @@ export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sessions = yield* SessionV2.Service
|
const sessions = yield* SessionV2.Service
|
||||||
|
const locations = yield* LocationServiceMap
|
||||||
const tools = yield* ApplicationTools.Service
|
const tools = yield* ApplicationTools.Service
|
||||||
const validation = yield* SessionModelValidation
|
const validation = yield* SessionModelValidation
|
||||||
|
yield* tools.register({
|
||||||
|
task: yield* TaskTool.make(sessions, (location, id) =>
|
||||||
|
AgentV2.Service.pipe(
|
||||||
|
Effect.flatMap((agents) => agents.get(id)),
|
||||||
|
Effect.provide(locations.get(location)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
tools: { register: tools.register },
|
tool: { register: tools.register },
|
||||||
sessions: {
|
session: {
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
sessions.create({
|
sessions.create({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
|
parentID: input.parentID,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
location: input.location,
|
location: input.location,
|
||||||
|
|
@ -111,7 +123,9 @@ export const layer = Layer.effect(
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
delivery: input.delivery,
|
delivery: input.delivery,
|
||||||
|
resume: input.resume,
|
||||||
}),
|
}),
|
||||||
|
resume: sessions.resume,
|
||||||
messages: (input) =>
|
messages: (input) =>
|
||||||
sessions.messages({
|
sessions.messages({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { SessionEvent } from "../session/event"
|
||||||
import { SessionInput } from "../session/input"
|
import { SessionInput } from "../session/input"
|
||||||
import { SessionMessage } from "../session/message"
|
import { SessionMessage } from "../session/message"
|
||||||
import { Prompt } from "../session/prompt"
|
import { Prompt } from "../session/prompt"
|
||||||
|
import type { SessionRunner } from "../session/runner"
|
||||||
import { Agent } from "./agent"
|
import { Agent } from "./agent"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { Model } from "./model"
|
import { Model } from "./model"
|
||||||
|
|
@ -65,6 +66,7 @@ export { MessageDecodeError }
|
||||||
|
|
||||||
export interface CreateInput {
|
export interface CreateInput {
|
||||||
readonly id?: ID
|
readonly id?: ID
|
||||||
|
readonly parentID?: ID
|
||||||
readonly agent?: Agent.ID
|
readonly agent?: Agent.ID
|
||||||
readonly model?: Model.Ref
|
readonly model?: Model.Ref
|
||||||
readonly location: Location.Ref
|
readonly location: Location.Ref
|
||||||
|
|
@ -75,6 +77,8 @@ export interface PromptInput {
|
||||||
readonly sessionID: ID
|
readonly sessionID: ID
|
||||||
readonly prompt: Prompt
|
readonly prompt: Prompt
|
||||||
readonly delivery?: Delivery
|
readonly delivery?: Delivery
|
||||||
|
/** Admit durably without scheduling execution. */
|
||||||
|
readonly resume?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SwitchModelInput {
|
export interface SwitchModelInput {
|
||||||
|
|
@ -107,6 +111,8 @@ export interface Interface {
|
||||||
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
|
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||||
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
|
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
|
||||||
|
/** Explicitly drain one Session and wait for the current execution chain to settle. */
|
||||||
|
readonly resume: (sessionID: ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||||
readonly switchModel: (
|
readonly switchModel: (
|
||||||
input: SwitchModelInput,
|
input: SwitchModelInput,
|
||||||
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
|
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ export type ListInput = typeof ListInput.Type
|
||||||
|
|
||||||
type CreateInput = {
|
type CreateInput = {
|
||||||
id?: SessionSchema.ID
|
id?: SessionSchema.ID
|
||||||
|
parentID?: SessionSchema.ID
|
||||||
agent?: AgentV2.ID
|
agent?: AgentV2.ID
|
||||||
model?: ModelV2.Ref
|
model?: ModelV2.Ref
|
||||||
location: Location.Ref
|
location: Location.Ref
|
||||||
|
|
@ -216,6 +217,7 @@ export const layer = Layer.effect(
|
||||||
slug: Slug.create(),
|
slug: Slug.create(),
|
||||||
version: InstallationVersion,
|
version: InstallationVersion,
|
||||||
projectID: project.id,
|
projectID: project.id,
|
||||||
|
parentID: input.parentID,
|
||||||
directory: input.location.directory,
|
directory: input.location.directory,
|
||||||
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
|
||||||
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
|
||||||
|
|
|
||||||
122
packages/core/src/tool/task.ts
Normal file
122
packages/core/src/tool/task.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
export * as TaskTool from "./task"
|
||||||
|
|
||||||
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
|
import { Cause, Effect, Schema, Scope } from "effect"
|
||||||
|
import { AgentV2 } from "../agent"
|
||||||
|
import { Location } from "../location"
|
||||||
|
import { SessionV2 } from "../session"
|
||||||
|
import { SessionMessage } from "../session/message"
|
||||||
|
import { Prompt } from "../session/prompt"
|
||||||
|
import { Tool } from "./tool"
|
||||||
|
|
||||||
|
export const Input = Schema.Struct({
|
||||||
|
description: Schema.String.annotate({ description: "A short description of the task" }),
|
||||||
|
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
|
||||||
|
subagent_type: Schema.String.annotate({ description: "The specialized agent to use" }),
|
||||||
|
background: Schema.optional(Schema.Boolean).annotate({
|
||||||
|
description: "Return immediately and notify the parent Session when the task finishes",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Output = Schema.Struct({
|
||||||
|
sessionID: SessionV2.ID,
|
||||||
|
status: Schema.Literals(["running", "completed"]),
|
||||||
|
output: Schema.String.pipe(Schema.optional),
|
||||||
|
})
|
||||||
|
|
||||||
|
type Sessions = Pick<SessionV2.Interface, "create" | "get" | "interrupt" | "messages" | "prompt" | "resume">
|
||||||
|
|
||||||
|
export const make = Effect.fn("TaskTool.make")(function* (
|
||||||
|
sessions: Sessions,
|
||||||
|
resolveAgent: (location: Location.Ref, id: AgentV2.ID) => Effect.Effect<AgentV2.Info | undefined>,
|
||||||
|
) {
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
|
||||||
|
return Tool.make({
|
||||||
|
description:
|
||||||
|
"Delegate focused work to a specialized child agent. Foreground calls wait for the result; background calls return immediately and notify this Session when complete.",
|
||||||
|
input: Input,
|
||||||
|
output: Output,
|
||||||
|
execute: (parameters, context) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const parent = yield* sessions.get(context.sessionID)
|
||||||
|
const agent = yield* resolveAgent(parent.location, AgentV2.ID.make(parameters.subagent_type))
|
||||||
|
if (!agent || (agent.mode !== "subagent" && agent.mode !== "all") || agent.hidden)
|
||||||
|
return yield* new ToolFailure({ message: `Unknown subagent: ${parameters.subagent_type}` })
|
||||||
|
const child = yield* sessions.create({
|
||||||
|
parentID: parent.id,
|
||||||
|
location: parent.location,
|
||||||
|
agent: agent.id,
|
||||||
|
model: agent.model ?? parent.model,
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: Replace this fresh-child-only composition once Session execution exposes a bounded
|
||||||
|
// activity/result identity. An admission ID alone cannot correlate a response when one drain
|
||||||
|
// processes later queued work.
|
||||||
|
const run = Effect.gen(function* () {
|
||||||
|
yield* sessions.prompt({
|
||||||
|
sessionID: child.id,
|
||||||
|
prompt: new Prompt({ text: parameters.prompt }),
|
||||||
|
delivery: "steer",
|
||||||
|
resume: false,
|
||||||
|
})
|
||||||
|
yield* sessions.resume(child.id)
|
||||||
|
const messages = yield* sessions.messages({ sessionID: child.id, order: "desc", limit: 1 })
|
||||||
|
const assistant = messages.find(
|
||||||
|
(message): message is SessionMessage.Assistant => message.type === "assistant" && !!message.time.completed,
|
||||||
|
)
|
||||||
|
if (!assistant) return ""
|
||||||
|
return assistant.content
|
||||||
|
.filter((part): part is SessionMessage.AssistantText => part.type === "text")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
if (parameters.background !== true) {
|
||||||
|
const output = yield* run.pipe(
|
||||||
|
Effect.onInterrupt(() => sessions.interrupt(child.id)),
|
||||||
|
Effect.mapError((error) => new ToolFailure({ message: `Task failed: ${String(error)}`, error })),
|
||||||
|
)
|
||||||
|
return { sessionID: child.id, status: "completed" as const, output }
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* run.pipe(
|
||||||
|
Effect.matchCauseEffect({
|
||||||
|
onSuccess: (output) => notify("completed", output),
|
||||||
|
onFailure: (cause) =>
|
||||||
|
Cause.hasInterruptsOnly(cause) ? Effect.void : notify("error", String(Cause.squash(cause))),
|
||||||
|
}),
|
||||||
|
Effect.tapCause((cause) => Effect.logError("Background task notification failed", Cause.squash(cause))),
|
||||||
|
Effect.ignore,
|
||||||
|
Effect.forkIn(scope, { startImmediately: true }),
|
||||||
|
)
|
||||||
|
return { sessionID: child.id, status: "running" as const }
|
||||||
|
|
||||||
|
function notify(state: "completed" | "error", text: string) {
|
||||||
|
const tag = state === "completed" ? "task_result" : "task_error"
|
||||||
|
return sessions.prompt({
|
||||||
|
sessionID: parent.id,
|
||||||
|
prompt: new Prompt({
|
||||||
|
text: `<task id="${child.id}" state="${state}">\n<summary>Background task ${state}: ${parameters.description}</summary>\n<${tag}>\n${text}\n</${tag}>\n</task>`,
|
||||||
|
}),
|
||||||
|
delivery: "steer",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
Effect.mapError((error) =>
|
||||||
|
error instanceof ToolFailure
|
||||||
|
? error
|
||||||
|
: new ToolFailure({ message: `Unable to run task: ${String(error)}`, error }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
toModelOutput: ({ output }) => [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text:
|
||||||
|
output.status === "running"
|
||||||
|
? `<task id="${output.sessionID}" state="running">\nThe task is working in the background. You will be notified automatically when it finishes.\n</task>`
|
||||||
|
: `<task id="${output.sessionID}" state="completed">\n<task_result>\n${output.output ?? ""}\n</task_result>\n</task>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -13,9 +13,9 @@ describe("public native OpenCode API", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const opencode = yield* OpenCode.Service
|
const opencode = yield* OpenCode.Service
|
||||||
|
|
||||||
expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"])
|
expect(Object.keys(opencode).sort()).toEqual(["session", "tool"])
|
||||||
|
|
||||||
expect(Object.keys(opencode.sessions).sort()).toEqual([
|
expect(Object.keys(opencode.session).sort()).toEqual([
|
||||||
"context",
|
"context",
|
||||||
"create",
|
"create",
|
||||||
"events",
|
"events",
|
||||||
|
|
@ -25,12 +25,13 @@ describe("public native OpenCode API", () => {
|
||||||
"message",
|
"message",
|
||||||
"messages",
|
"messages",
|
||||||
"prompt",
|
"prompt",
|
||||||
|
"resume",
|
||||||
"switchModel",
|
"switchModel",
|
||||||
])
|
])
|
||||||
expect(Session.ID.create()).toStartWith("ses_")
|
expect(Session.ID.create()).toStartWith("ses_")
|
||||||
expect(Session.MessageID.create()).toStartWith("msg_")
|
expect(Session.MessageID.create()).toStartWith("msg_")
|
||||||
expect(yield* opencode.sessions.list()).toBeArray()
|
expect(yield* opencode.session.list()).toBeArray()
|
||||||
yield* opencode.tools.register({
|
yield* opencode.tool.register({
|
||||||
public_tool: Tool.make({
|
public_tool: Tool.make({
|
||||||
description: "Public tool",
|
description: "Public tool",
|
||||||
input: Schema.Struct({}),
|
input: Schema.Struct({}),
|
||||||
|
|
@ -52,14 +53,14 @@ describe("public native OpenCode API", () => {
|
||||||
const opencode = yield* OpenCode.Service
|
const opencode = yield* OpenCode.Service
|
||||||
const sessionID = Session.ID.make("ses_public_switch_available")
|
const sessionID = Session.ID.make("ses_public_switch_available")
|
||||||
const model = ref({ variant: "fast" })
|
const model = ref({ variant: "fast" })
|
||||||
yield* opencode.sessions.create({
|
yield* opencode.session.create({
|
||||||
id: sessionID,
|
id: sessionID,
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
|
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* opencode.sessions.switchModel({ sessionID, model })
|
yield* opencode.session.switchModel({ sessionID, model })
|
||||||
|
|
||||||
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
|
expect((yield* opencode.session.get(sessionID)).model).toEqual(model)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -77,27 +78,27 @@ describe("public native OpenCode API", () => {
|
||||||
const opencode = yield* OpenCode.Service
|
const opencode = yield* OpenCode.Service
|
||||||
const availableID = Session.ID.make("ses_public_switch_exact_available")
|
const availableID = Session.ID.make("ses_public_switch_exact_available")
|
||||||
const disabledID = Session.ID.make("ses_public_switch_exact_disabled")
|
const disabledID = Session.ID.make("ses_public_switch_exact_disabled")
|
||||||
yield* opencode.sessions.create({
|
yield* opencode.session.create({
|
||||||
id: availableID,
|
id: availableID,
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }),
|
location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }),
|
||||||
})
|
})
|
||||||
yield* opencode.sessions.create({
|
yield* opencode.session.create({
|
||||||
id: disabledID,
|
id: disabledID,
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }),
|
location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
|
yield* opencode.session.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
|
||||||
const disabledError = yield* opencode.sessions
|
const disabledError = yield* opencode.session
|
||||||
.switchModel({ sessionID: disabledID, model: ref() })
|
.switchModel({ sessionID: disabledID, model: ref() })
|
||||||
.pipe(Effect.flip)
|
.pipe(Effect.flip)
|
||||||
const missingError = yield* opencode.sessions
|
const missingError = yield* opencode.session
|
||||||
.switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) })
|
.switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) })
|
||||||
.pipe(Effect.flip)
|
.pipe(Effect.flip)
|
||||||
|
|
||||||
expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError)
|
expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError)
|
||||||
expect(missingError).toBeInstanceOf(Session.ModelUnavailableError)
|
expect(missingError).toBeInstanceOf(Session.ModelUnavailableError)
|
||||||
expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" }))
|
expect((yield* opencode.session.get(availableID)).model).toEqual(ref({ variant: "default" }))
|
||||||
expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined()
|
expect((yield* opencode.session.get(disabledID)).model).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -114,18 +115,18 @@ describe("public native OpenCode API", () => {
|
||||||
const opencode = yield* OpenCode.Service
|
const opencode = yield* OpenCode.Service
|
||||||
const sessionID = Session.ID.make("ses_public_switch_variant")
|
const sessionID = Session.ID.make("ses_public_switch_variant")
|
||||||
const selected = ref({ variant: "fast" })
|
const selected = ref({ variant: "fast" })
|
||||||
yield* opencode.sessions.create({
|
yield* opencode.session.create({
|
||||||
id: sessionID,
|
id: sessionID,
|
||||||
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
|
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
|
||||||
})
|
})
|
||||||
yield* opencode.sessions.switchModel({ sessionID, model: selected })
|
yield* opencode.session.switchModel({ sessionID, model: selected })
|
||||||
|
|
||||||
const error = yield* opencode.sessions
|
const error = yield* opencode.session
|
||||||
.switchModel({ sessionID, model: ref({ variant: "unknown" }) })
|
.switchModel({ sessionID, model: ref({ variant: "unknown" }) })
|
||||||
.pipe(Effect.flip)
|
.pipe(Effect.flip)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Session.VariantUnavailableError)
|
expect(error).toBeInstanceOf(Session.VariantUnavailableError)
|
||||||
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected)
|
expect((yield* opencode.session.get(sessionID)).model).toEqual(selected)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -135,7 +136,7 @@ describe("public native OpenCode API", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const opencode = yield* OpenCode.Service
|
const opencode = yield* OpenCode.Service
|
||||||
const sessionID = Session.ID.make("ses_public_switch_missing")
|
const sessionID = Session.ID.make("ses_public_switch_missing")
|
||||||
const error = yield* opencode.sessions
|
const error = yield* opencode.session
|
||||||
.switchModel({
|
.switchModel({
|
||||||
sessionID,
|
sessionID,
|
||||||
model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }),
|
model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }),
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ describe("SessionV2.create", () => {
|
||||||
it.effect("stores supplied immutable create attributes", () =>
|
it.effect("stores supplied immutable create attributes", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
|
const parentID = SessionV2.ID.make("ses_parent")
|
||||||
const workspaceID = WorkspaceV2.ID.make("wrk_test")
|
const workspaceID = WorkspaceV2.ID.make("wrk_test")
|
||||||
const model = ModelV2.Ref.make({
|
const model = ModelV2.Ref.make({
|
||||||
id: ModelV2.ID.make("sonnet"),
|
id: ModelV2.ID.make("sonnet"),
|
||||||
|
|
@ -104,10 +105,11 @@ describe("SessionV2.create", () => {
|
||||||
expect(
|
expect(
|
||||||
yield* session.create({
|
yield* session.create({
|
||||||
location: Location.Ref.make({ directory: location.directory, workspaceID }),
|
location: Location.Ref.make({ directory: location.directory, workspaceID }),
|
||||||
|
parentID,
|
||||||
agent: AgentV2.ID.make("build"),
|
agent: AgentV2.ID.make("build"),
|
||||||
model,
|
model,
|
||||||
}),
|
}),
|
||||||
).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
|
).toMatchObject({ parentID, location: { directory: location.directory, workspaceID }, agent: "build", model })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
206
packages/core/test/tool-task.test.ts
Normal file
206
packages/core/test/tool-task.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
|
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||||
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
|
import { TaskTool } from "@opencode-ai/core/tool/task"
|
||||||
|
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||||
|
import { DateTime, Deferred, Effect, Fiber, Layer } from "effect"
|
||||||
|
import { toolIdentity } from "./lib/tool"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
const parentID = SessionV2.ID.make("ses_task_parent")
|
||||||
|
const childID = SessionV2.ID.make("ses_task_child")
|
||||||
|
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||||
|
const parent = new SessionV2.Info({
|
||||||
|
id: parentID,
|
||||||
|
projectID: ProjectV2.ID.make("project"),
|
||||||
|
agent: AgentV2.ID.make("build"),
|
||||||
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||||
|
title: "Parent",
|
||||||
|
location,
|
||||||
|
})
|
||||||
|
const child = new SessionV2.Info({
|
||||||
|
id: childID,
|
||||||
|
parentID,
|
||||||
|
projectID: parent.projectID,
|
||||||
|
agent: parent.agent,
|
||||||
|
model: parent.model,
|
||||||
|
cost: 0,
|
||||||
|
tokens: parent.tokens,
|
||||||
|
time: parent.time,
|
||||||
|
title: "Child",
|
||||||
|
location,
|
||||||
|
})
|
||||||
|
const assistant = new SessionMessage.Assistant({
|
||||||
|
id: SessionMessage.ID.make("msg_task_assistant"),
|
||||||
|
type: "assistant",
|
||||||
|
agent: "explore",
|
||||||
|
model: parent.model!,
|
||||||
|
content: [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Task output" })],
|
||||||
|
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
||||||
|
})
|
||||||
|
const input = {
|
||||||
|
description: "Map auth",
|
||||||
|
prompt: "Map the authentication flow",
|
||||||
|
subagent_type: "explore",
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TaskTool", () => {
|
||||||
|
const it = testEffect(Layer.empty)
|
||||||
|
const resolveAgent = (): Effect.Effect<AgentV2.Info | undefined> =>
|
||||||
|
Effect.succeed(AgentV2.Info.empty(AgentV2.ID.make("explore")))
|
||||||
|
|
||||||
|
it.effect("runs a foreground child with an admit-only steer and explicit resume", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const inputs: Parameters<SessionV2.Interface["prompt"]>[0][] = []
|
||||||
|
let resumed = 0
|
||||||
|
const sessions = mockSessions({
|
||||||
|
prompt: (value) => {
|
||||||
|
inputs.push(value)
|
||||||
|
return Effect.succeed(admission(value))
|
||||||
|
},
|
||||||
|
resume: () => Effect.sync(() => resumed++),
|
||||||
|
})
|
||||||
|
const tool = yield* TaskTool.make(sessions, resolveAgent)
|
||||||
|
|
||||||
|
const result = yield* execute(tool, { ...input, background: false }, "call_task")
|
||||||
|
|
||||||
|
expect(result.structured).toEqual({ sessionID: childID, status: "completed", output: "Task output" })
|
||||||
|
expect(inputs).toHaveLength(1)
|
||||||
|
expect(inputs[0]).toMatchObject({ sessionID: childID, delivery: "steer", resume: false })
|
||||||
|
expect(resumed).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects an unknown subagent before creating a child", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let created = false
|
||||||
|
const sessions = mockSessions({
|
||||||
|
create: () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
created = true
|
||||||
|
return child
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const tool = yield* TaskTool.make(sessions, () => Effect.succeed(undefined))
|
||||||
|
|
||||||
|
const error = yield* execute(tool, { ...input, subagent_type: "missing" }, "call_task_unknown").pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(error.message).toBe("Unknown subagent: missing")
|
||||||
|
expect(created).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("does not notify the parent when background work is interrupted", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let notified = false
|
||||||
|
const sessions = mockSessions({
|
||||||
|
prompt: (value) => {
|
||||||
|
if (value.sessionID === parentID) notified = true
|
||||||
|
return Effect.succeed(admission(value))
|
||||||
|
},
|
||||||
|
resume: () => Effect.interrupt,
|
||||||
|
})
|
||||||
|
const tool = yield* TaskTool.make(sessions, resolveAgent)
|
||||||
|
|
||||||
|
const result = yield* execute(tool, { ...input, background: true }, "call_task_background_interrupt")
|
||||||
|
|
||||||
|
expect(result.structured).toEqual({ sessionID: childID, status: "running" })
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
expect(notified).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("interrupts the child when foreground waiting is interrupted", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const resumed = yield* Deferred.make<void>()
|
||||||
|
const interrupts: SessionV2.ID[] = []
|
||||||
|
const sessions = mockSessions({
|
||||||
|
resume: () => Deferred.succeed(resumed, undefined).pipe(Effect.andThen(Effect.never)),
|
||||||
|
interrupt: (sessionID) => Effect.sync(() => interrupts.push(sessionID)),
|
||||||
|
})
|
||||||
|
const tool = yield* TaskTool.make(sessions, resolveAgent)
|
||||||
|
const fiber = yield* execute(tool, input, "call_task_interrupt").pipe(Effect.forkChild)
|
||||||
|
|
||||||
|
yield* Deferred.await(resumed)
|
||||||
|
yield* Fiber.interrupt(fiber)
|
||||||
|
expect(interrupts).toEqual([childID])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("returns before background completion and steers the result into the parent", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const gate = yield* Deferred.make<void>()
|
||||||
|
const notified = yield* Deferred.make<Parameters<SessionV2.Interface["prompt"]>[0]>()
|
||||||
|
const inputs: Parameters<SessionV2.Interface["prompt"]>[0][] = []
|
||||||
|
const sessions = mockSessions({
|
||||||
|
prompt: (value) => {
|
||||||
|
inputs.push(value)
|
||||||
|
return value.sessionID === parentID
|
||||||
|
? Deferred.succeed(notified, value).pipe(Effect.as(admission(value)))
|
||||||
|
: Effect.succeed(admission(value))
|
||||||
|
},
|
||||||
|
resume: () => Deferred.await(gate),
|
||||||
|
})
|
||||||
|
const tool = yield* TaskTool.make(sessions, resolveAgent)
|
||||||
|
|
||||||
|
const result = yield* execute(tool, { ...input, background: true }, "call_task_background")
|
||||||
|
|
||||||
|
expect(result.structured).toEqual({ sessionID: childID, status: "running" })
|
||||||
|
expect(inputs).toHaveLength(1)
|
||||||
|
yield* Deferred.succeed(gate, undefined)
|
||||||
|
const notification = yield* Deferred.await(notified)
|
||||||
|
expect(notification).toMatchObject({ sessionID: parentID, delivery: "steer" })
|
||||||
|
expect(notification.prompt.text).toContain("Background task completed: Map auth")
|
||||||
|
expect(notification.prompt.text).toContain("Task output")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockSessions(overrides: {
|
||||||
|
create?: SessionV2.Interface["create"]
|
||||||
|
interrupt?: SessionV2.Interface["interrupt"]
|
||||||
|
prompt?: SessionV2.Interface["prompt"]
|
||||||
|
resume?: SessionV2.Interface["resume"]
|
||||||
|
}): Pick<SessionV2.Interface, "create" | "get" | "interrupt" | "messages" | "prompt" | "resume"> {
|
||||||
|
return {
|
||||||
|
create: overrides.create ?? (() => Effect.succeed(child)),
|
||||||
|
get: (id) => Effect.succeed(id === parentID ? parent : child),
|
||||||
|
prompt: overrides.prompt ?? ((value) => Effect.succeed(admission(value))),
|
||||||
|
resume: overrides.resume ?? (() => Effect.void),
|
||||||
|
messages: () => Effect.succeed([assistant]),
|
||||||
|
interrupt: overrides.interrupt ?? (() => Effect.void),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function admission(input: Parameters<SessionV2.Interface["prompt"]>[0]) {
|
||||||
|
return new SessionInput.Admitted({
|
||||||
|
admittedSeq: 1,
|
||||||
|
id: input.id ?? SessionMessage.ID.create(),
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: input.prompt,
|
||||||
|
delivery: input.delivery ?? "steer",
|
||||||
|
timeCreated: DateTime.makeUnsafe(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function execute(tool: Tool.AnyTool, input: unknown, toolCallID: string) {
|
||||||
|
return Tool.settle(
|
||||||
|
tool,
|
||||||
|
{ type: "tool-call", id: toolCallID, name: "task", input },
|
||||||
|
{
|
||||||
|
sessionID: parentID,
|
||||||
|
...toolIdentity,
|
||||||
|
toolCallID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue