feat(core): add v2 background task tool
This commit is contained in:
parent
12acb9a59a
commit
74a269af7d
7 changed files with 352 additions and 25 deletions
|
|
@ -13,9 +13,9 @@ describe("public native OpenCode API", () => {
|
|||
Effect.gen(function* () {
|
||||
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",
|
||||
"create",
|
||||
"events",
|
||||
|
|
@ -25,12 +25,13 @@ describe("public native OpenCode API", () => {
|
|||
"message",
|
||||
"messages",
|
||||
"prompt",
|
||||
"resume",
|
||||
"switchModel",
|
||||
])
|
||||
expect(Session.ID.create()).toStartWith("ses_")
|
||||
expect(Session.MessageID.create()).toStartWith("msg_")
|
||||
expect(yield* opencode.sessions.list()).toBeArray()
|
||||
yield* opencode.tools.register({
|
||||
expect(yield* opencode.session.list()).toBeArray()
|
||||
yield* opencode.tool.register({
|
||||
public_tool: Tool.make({
|
||||
description: "Public tool",
|
||||
input: Schema.Struct({}),
|
||||
|
|
@ -52,14 +53,14 @@ describe("public native OpenCode API", () => {
|
|||
const opencode = yield* OpenCode.Service
|
||||
const sessionID = Session.ID.make("ses_public_switch_available")
|
||||
const model = ref({ variant: "fast" })
|
||||
yield* opencode.sessions.create({
|
||||
yield* opencode.session.create({
|
||||
id: sessionID,
|
||||
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 availableID = Session.ID.make("ses_public_switch_exact_available")
|
||||
const disabledID = Session.ID.make("ses_public_switch_exact_disabled")
|
||||
yield* opencode.sessions.create({
|
||||
yield* opencode.session.create({
|
||||
id: availableID,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }),
|
||||
})
|
||||
yield* opencode.sessions.create({
|
||||
yield* opencode.session.create({
|
||||
id: disabledID,
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }),
|
||||
})
|
||||
|
||||
yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
|
||||
const disabledError = yield* opencode.sessions
|
||||
yield* opencode.session.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
|
||||
const disabledError = yield* opencode.session
|
||||
.switchModel({ sessionID: disabledID, model: ref() })
|
||||
.pipe(Effect.flip)
|
||||
const missingError = yield* opencode.sessions
|
||||
const missingError = yield* opencode.session
|
||||
.switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError)
|
||||
expect(missingError).toBeInstanceOf(Session.ModelUnavailableError)
|
||||
expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" }))
|
||||
expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined()
|
||||
expect((yield* opencode.session.get(availableID)).model).toEqual(ref({ variant: "default" }))
|
||||
expect((yield* opencode.session.get(disabledID)).model).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
@ -114,18 +115,18 @@ describe("public native OpenCode API", () => {
|
|||
const opencode = yield* OpenCode.Service
|
||||
const sessionID = Session.ID.make("ses_public_switch_variant")
|
||||
const selected = ref({ variant: "fast" })
|
||||
yield* opencode.sessions.create({
|
||||
yield* opencode.session.create({
|
||||
id: sessionID,
|
||||
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" }) })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
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* () {
|
||||
const opencode = yield* OpenCode.Service
|
||||
const sessionID = Session.ID.make("ses_public_switch_missing")
|
||||
const error = yield* opencode.sessions
|
||||
const error = yield* opencode.session
|
||||
.switchModel({
|
||||
sessionID,
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionV2.Service
|
||||
const parentID = SessionV2.ID.make("ses_parent")
|
||||
const workspaceID = WorkspaceV2.ID.make("wrk_test")
|
||||
const model = ModelV2.Ref.make({
|
||||
id: ModelV2.ID.make("sonnet"),
|
||||
|
|
@ -104,10 +105,11 @@ describe("SessionV2.create", () => {
|
|||
expect(
|
||||
yield* session.create({
|
||||
location: Location.Ref.make({ directory: location.directory, workspaceID }),
|
||||
parentID,
|
||||
agent: AgentV2.ID.make("build"),
|
||||
model,
|
||||
}),
|
||||
).toMatchObject({ location: { directory: location.directory, workspaceID }, agent: "build", model })
|
||||
).toMatchObject({ parentID, location: { directory: location.directory, workspaceID }, agent: "build", model })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
185
packages/core/test/tool-task.test.ts
Normal file
185
packages/core/test/tool-task.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
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 { DateTime, Deferred, Effect, Layer, Stream } from "effect"
|
||||
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) },
|
||||
})
|
||||
|
||||
describe("TaskTool", () => {
|
||||
const it = testEffect(Layer.empty)
|
||||
const resolveAgent = () => 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: (input) => {
|
||||
inputs.push(input)
|
||||
return Effect.succeed(admission(input))
|
||||
},
|
||||
resume: () => Effect.sync(() => resumed++),
|
||||
})
|
||||
const tool = yield* TaskTool.make(sessions, resolveAgent)
|
||||
|
||||
const result = yield* tool.execute(
|
||||
{
|
||||
description: "Map auth",
|
||||
prompt: "Map the authentication flow",
|
||||
subagent_type: "explore",
|
||||
background: false,
|
||||
},
|
||||
{ sessionID: parentID, id: "call_task", name: "task" },
|
||||
)
|
||||
|
||||
expect(result).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
|
||||
}),
|
||||
prompt: (input) => Effect.succeed(admission(input)),
|
||||
resume: () => Effect.void,
|
||||
})
|
||||
const tool = yield* TaskTool.make(sessions, () => Effect.succeed(undefined))
|
||||
|
||||
const error = yield* tool
|
||||
.execute(
|
||||
{
|
||||
description: "Map auth",
|
||||
prompt: "Map the authentication flow",
|
||||
subagent_type: "missing",
|
||||
},
|
||||
{ sessionID: parentID, id: "call_task_unknown", name: "task" },
|
||||
)
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toBe("Unknown subagent: missing")
|
||||
expect(created).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
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: (input) => {
|
||||
inputs.push(input)
|
||||
return input.sessionID === parentID
|
||||
? Deferred.succeed(notified, input).pipe(Effect.as(admission(input)))
|
||||
: Effect.succeed(admission(input))
|
||||
},
|
||||
resume: () => Deferred.await(gate),
|
||||
})
|
||||
const tool = yield* TaskTool.make(sessions, resolveAgent)
|
||||
|
||||
const result = yield* tool.execute(
|
||||
{
|
||||
description: "Map auth",
|
||||
prompt: "Map the authentication flow",
|
||||
subagent_type: "explore",
|
||||
background: true,
|
||||
},
|
||||
{ sessionID: parentID, id: "call_task_background", name: "task" },
|
||||
)
|
||||
|
||||
expect(result).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"]
|
||||
prompt: SessionV2.Interface["prompt"]
|
||||
resume: SessionV2.Interface["resume"]
|
||||
}): SessionV2.Interface {
|
||||
return {
|
||||
create: overrides.create ?? (() => Effect.succeed(child)),
|
||||
get: (id) => Effect.succeed(id === parentID ? parent : child),
|
||||
prompt: overrides.prompt,
|
||||
resume: overrides.resume,
|
||||
messages: () => Effect.succeed([assistant]),
|
||||
list: () => Effect.succeed([]),
|
||||
message: () => Effect.succeed(undefined),
|
||||
context: () => Effect.succeed([]),
|
||||
events: () => Stream.die("unused"),
|
||||
switchAgent: () => Effect.die("unused"),
|
||||
switchModel: () => Effect.die("unused"),
|
||||
shell: () => Effect.die("unused"),
|
||||
skill: () => Effect.die("unused"),
|
||||
compact: () => Effect.die("unused"),
|
||||
wait: () => Effect.die("unused"),
|
||||
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),
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue