feat(plugin): add tool progress reporting

This commit is contained in:
Dax Raad 2026-07-14 15:57:16 -04:00
parent 6039f54126
commit 915be9b54d
36 changed files with 196 additions and 92 deletions

View file

@ -333,8 +333,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
assistantMessageID: event.assistantMessageID,
toolCallID: event.toolCallID,
messageID: event.messageID,
callID: event.callID,
input: event.input,
}
return Reflect.apply(callback, undefined, [output]).pipe(
@ -347,8 +347,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
assistantMessageID: event.assistantMessageID,
toolCallID: event.toolCallID,
messageID: event.messageID,
callID: event.callID,
input: event.input,
result: event.result,
output: event.output,

View file

@ -256,10 +256,22 @@ function fromPromiseTool(tool: AnyTool) {
if ("jsonSchema" in tool)
return Tool.make({
...tool,
execute: (input, context) => Effect.promise(() => tool.execute(input, context)),
execute: (input, context) =>
Effect.promise(() =>
tool.execute(input, {
...context,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
),
})
return Tool.make({
...tool,
execute: (input, context) => Effect.promise(() => tool.execute(input, context)),
execute: (input, context) =>
Effect.promise(() =>
tool.execute(input, {
...context,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
),
})
}

View file

@ -265,8 +265,18 @@ const layer = Layer.effect(
toolMaterialization.settle({
sessionID: session.id,
agent: agent.id,
assistantMessageID,
messageID: assistantMessageID,
call: event,
progress: (update) =>
serialized(
events.publish(SessionEvent.Tool.Progress, {
sessionID: session.id,
assistantMessageID,
callID: event.id,
structured: { ...update.structured },
content: [...update.content],
}),
),
}),
).pipe(
Effect.flatMap((settlement) =>

View file

@ -18,7 +18,7 @@ export const MANAGED_DIRECTORY = "tool-output"
export interface BoundInput {
readonly sessionID: SessionSchema.ID
readonly toolCallID: string
readonly callID: string
readonly output: ToolOutput
}

View file

@ -122,8 +122,8 @@ export const Plugin = {
return Effect.gen(function* () {
const permissionSource = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
if (input.oldString === input.newString) {
return yield* new ToolFailure({

View file

@ -113,12 +113,13 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const output = yield* settle(
registration.tool,
{ type: "tool-call", id: context.toolCallID, name, input },
{ type: "tool-call", id: context.callID, name, input },
{
sessionID: context.sessionID,
agent: context.agent,
assistantMessageID: context.assistantMessageID,
toolCallID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
progress: context.progress,
},
).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(output)

View file

@ -72,7 +72,7 @@ export const Plugin = {
},
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const cwd = path.resolve(location.directory, input.path ?? ".")
yield* fs

View file

@ -90,7 +90,7 @@ export const Plugin = {
},
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs

View file

@ -12,8 +12,8 @@ export interface BeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly messageID: SessionMessage.ID
readonly callID: string
input: unknown
}
@ -21,8 +21,8 @@ export interface AfterEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly messageID: SessionMessage.ID
readonly callID: string
readonly input: unknown
result: ToolResultValue
output?: ToolOutput

View file

@ -57,8 +57,8 @@ export const layer = Layer.effectDiscard(
agent: context.agent,
source: {
type: "tool",
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
},
})
const result = yield* mcp

View file

@ -85,8 +85,8 @@ export const Plugin = {
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.try({

View file

@ -73,7 +73,7 @@ export const Plugin = {
resources: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
.pipe(
Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
@ -84,7 +84,7 @@ export const Plugin = {
title: "Questions",
metadata: {
kind: "question",
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
tool: { messageID: context.messageID, callID: context.callID },
},
fields: [
toField(input.questions[0], 0),

View file

@ -62,8 +62,8 @@ export const Plugin = {
return Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
const target = yield* mutation.resolve({ path: input.path, kind: "directory" })
const external = target.externalDirectory

View file

@ -19,8 +19,14 @@ import { toSessionError } from "../session/to-session-error"
export type ExecuteInput = {
readonly sessionID: SessionSchema.ID
readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Progress) => Effect.Effect<void>
}
export interface Progress {
readonly structured: Readonly<Record<string, unknown>>
readonly content: ToolOutput["content"]
}
export interface Interface {
@ -65,8 +71,8 @@ const registryLayer = Layer.effect(
tool: input.call.name,
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
messageID: input.messageID,
callID: input.call.id,
input: input.call.input,
}
yield* toolHooks.runBefore(beforeEvent)
@ -76,8 +82,22 @@ const registryLayer = Layer.effect(
{
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
messageID: input.messageID,
callID: input.call.id,
progress: (update) =>
input.progress?.({
structured: update.structured,
content: (update.content ?? []).map((part) =>
part.type === "text"
? { type: "text" as const, text: part.text }
: {
type: "file" as const,
uri: `data:${part.mime};base64,${part.data}`,
mime: part.mime,
name: part.name,
},
),
}) ?? Effect.void,
},
).pipe(
Effect.map((output) => ({ output })),
@ -94,7 +114,7 @@ const registryLayer = Layer.effect(
} else {
const bounded = yield* resources.bound({
sessionID: input.sessionID,
toolCallID: input.call.id,
callID: input.call.id,
output: pending.output,
})
const result = ToolOutput.toResultValue(bounded.output)
@ -111,8 +131,8 @@ const registryLayer = Layer.effect(
tool: input.call.name,
sessionID: input.sessionID,
agent: input.agent,
assistantMessageID: input.assistantMessageID,
toolCallID: input.call.id,
messageID: input.messageID,
callID: input.call.id,
input: beforeEvent.input,
result: settlement.result,
output: settlement.output,

View file

@ -165,8 +165,8 @@ export const Plugin = {
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
const external = target.externalDirectory
@ -231,7 +231,7 @@ export const Plugin = {
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.toolCallID,
id: context.callID,
type: name,
title: input.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
@ -240,7 +240,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@ -255,7 +255,7 @@ export const Plugin = {
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,

View file

@ -79,7 +79,7 @@ export const Plugin = {
save: [skill.id],
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const directory = path.dirname(skill.location)
const files =

View file

@ -136,8 +136,8 @@ export const Plugin = {
agent: context.agent,
source: {
type: "tool",
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
},
})
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
@ -160,6 +160,9 @@ export const Plugin = {
)
const background = input.background === true
yield* context.progress({
structured: { sessionID: child.id, status: "running" },
})
const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input.

View file

@ -141,7 +141,7 @@ export const Plugin = {
metadata: input,
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const { body, contentType } = yield* Effect.gen(function* () {

View file

@ -213,7 +213,7 @@ export const Plugin = {
metadata: { ...input, provider },
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
source: { type: "tool", messageID: context.messageID, callID: context.callID },
})
const text =

View file

@ -64,8 +64,8 @@ export const Plugin = {
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
messageID: context.messageID,
callID: context.callID,
}
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
const external = target.externalDirectory

View file

@ -10,7 +10,7 @@ import { host } from "../plugin/host"
export const toolIdentity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_tool_test"),
messageID: SessionMessage.ID.make("msg_tool_test"),
}
export const toolDefinitions = (registry: ToolRegistry.Interface, permissions?: PermissionV2.Ruleset) =>

View file

@ -663,7 +663,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
agent: toolIdentity.agent,
source: {
type: "tool",
messageID: toolIdentity.assistantMessageID,
messageID: toolIdentity.messageID,
callID: "call_mcp_permission",
},
})

View file

@ -363,7 +363,7 @@ describe("PluginV2", () => {
const settlement = yield* materialized.settle({
sessionID: SessionV2.ID.make("ses_hooks"),
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_hooks"),
messageID: SessionMessage.ID.make("msg_hooks"),
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
})

View file

@ -129,6 +129,7 @@ describe("fromPromise", () => {
const plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service
const host = yield* PluginHost.make(plugins)
const progress: ToolRegistry.Progress[] = []
const promisePlugin = Plugin.define({
id: "promise-tool",
setup: async (ctx) => {
@ -139,7 +140,10 @@ describe("fromPromise", () => {
description: "Hello",
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: async ({ name }) => `Hello, ${name}!`,
execute: async ({ name }, context) => {
await context.progress({ structured: { phase: "greeting" } })
return `Hello, ${name}!`
},
})
})
},
@ -153,10 +157,12 @@ describe("fromPromise", () => {
yield* materialized.settle({
sessionID: SessionV2.ID.make("ses_promise_tool"),
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_promise_tool"),
messageID: SessionMessage.ID.make("msg_promise_tool"),
progress: (update) => Effect.sync(() => progress.push(update)),
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
}),
).toMatchObject({ result: { type: "text", value: "Hello, world!" } })
expect(progress).toEqual([{ structured: { phase: "greeting" }, content: [] }])
}),
)
})

View file

@ -109,7 +109,7 @@ const it = testEffect(testLayer)
const identity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_nearby"),
messageID: SessionMessage.ID.make("msg_nearby"),
}
const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({
sessionID,

View file

@ -182,7 +182,7 @@ test("binary failure emits no success event", async () => {
test("success event data can carry a provider-executed result", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID,
assistantMessageID: SessionMessage.ID.create(),
messageID: SessionMessage.ID.create(),
callID: "call-old",
structured: { type: "media", mime: "image/png" },
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],

View file

@ -15,10 +15,10 @@ const bounds: ToolOutputStore.BoundInput[] = []
const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore = Layer.mock(ToolOutputStore.Service, {
bound: (input) => {
if (input.toolCallID === "call-retention-failure") return Effect.fail(retentionFailure)
if (input.callID === "call-retention-failure") return Effect.fail(retentionFailure)
return Effect.sync(() => bounds.push(input)).pipe(
Effect.as(
input.toolCallID === "call-bounded"
input.callID === "call-bounded"
? {
output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
@ -32,7 +32,7 @@ const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.
const it = testEffect(registryLayer)
const identity = {
agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_registry"),
messageID: SessionMessage.ID.make("msg_registry"),
}
const sessionID = SessionV2.ID.make("ses_registry")
const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({
@ -240,7 +240,9 @@ describe("ToolRegistry", () => {
...identity,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
})
expect(contexts).toEqual([{ sessionID, ...identity, toolCallID: "call-context" }])
expect(contexts).toEqual([
{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) },
])
}),
)

View file

@ -783,14 +783,22 @@ describe("SessionRunnerLLM", () => {
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.sync(() => {
Effect.gen(function* () {
contexts.push(context)
yield* context.progress({ structured: { phase: "reading" } })
return { answer: query.toUpperCase() }
}),
}),
}, { codemode: false })
yield* admit(session, "Use application context")
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
const events = yield* EventV2.Service
const progressFiber = yield* events.subscribe(SessionEvent.Tool.Progress).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.callID === "call-location"),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
yield* session.resume(sessionID)
@ -799,10 +807,12 @@ describe("SessionRunnerLLM", () => {
{
sessionID,
agent: AgentV2.ID.make("build"),
assistantMessageID: expect.stringMatching(/^msg_/),
toolCallID: "call-location",
messageID: expect.stringMatching(/^msg_/),
callID: "call-location",
progress: expect.any(Function),
},
])
expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.structured).toEqual({ phase: "reading" })
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use application context" },
{
@ -2252,7 +2262,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"])
expect(authorizations).toMatchObject([{ sessionID, toolCallID: "call-echo" }])
expect(authorizations).toMatchObject([{ sessionID, callID: "call-echo" }])
expect(executions).toEqual(["hello"])
const context = yield* session.context(sessionID)
expect(context).toMatchObject([

View file

@ -26,8 +26,9 @@ test("execute preserves successful results with visible unhandled rejections", a
{
sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_execute"),
toolCallID: "call_execute",
messageID: SessionMessage.ID.make("msg_execute"),
callID: "call_execute",
progress: () => Effect.void,
},
),
)

View file

@ -52,7 +52,7 @@ describe("ToolOutputStore", () => {
const second = "y".repeat(30_000) + "-TAIL"
const result = yield* store.bound({
sessionID,
toolCallID: "call-aggregate",
callID: "call-aggregate",
output: {
structured: { kind: "report" },
content: [
@ -74,7 +74,7 @@ describe("ToolOutputStore", () => {
withStore(({ store, fs }) =>
Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } })
const result = yield* store.bound({ sessionID, callID: "call-json", output: { structured, content: [] } })
expect(result.output.structured).toEqual(structured)
expect(result.outputPaths).toHaveLength(1)
expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
@ -89,7 +89,7 @@ describe("ToolOutputStore", () => {
const data = "a".repeat(6 * 1024 * 1024)
const result = yield* store.bound({
sessionID,
toolCallID: "call-file",
callID: "call-file",
output: {
structured: { caption: "pixel" },
content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
@ -120,7 +120,7 @@ describe("ToolOutputStore", () => {
}
const result = yield* store.bound({
sessionID,
toolCallID: "call-text-and-media",
callID: "call-text-and-media",
output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] },
})
@ -136,7 +136,7 @@ describe("ToolOutputStore", () => {
Effect.gen(function* () {
const text = "x".repeat(30_000)
const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
expect(yield* store.bound({ sessionID, toolCallID: "call-duplicated", output })).toEqual({
expect(yield* store.bound({ sessionID, callID: "call-duplicated", output })).toEqual({
output,
outputPaths: [],
})
@ -151,7 +151,7 @@ describe("ToolOutputStore", () => {
const exit = yield* store
.bound({
sessionID,
toolCallID: "call-lossy",
callID: "call-lossy",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
})
.pipe(Effect.exit)
@ -166,7 +166,7 @@ describe("ToolOutputStore", () => {
withStore(({ store }) =>
Effect.gen(function* () {
const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
expect(yield* store.bound({ sessionID, toolCallID: "call-unencodable", output })).toEqual({
expect(yield* store.bound({ sessionID, callID: "call-unencodable", output })).toEqual({
output,
outputPaths: [],
})
@ -197,7 +197,7 @@ describe("ToolOutputStore", () => {
const fiber = yield* service
.bound({
sessionID,
toolCallID: "call-interrupted",
callID: "call-interrupted",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
})
.pipe(Effect.forkChild)
@ -216,7 +216,7 @@ describe("ToolOutputStore", () => {
expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
const result = yield* store.bound({
sessionID,
toolCallID: "call-config",
callID: "call-config",
output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
})
expect(result.outputPaths).toHaveLength(1)

View file

@ -166,7 +166,7 @@ describe("QuestionTool", () => {
expect(capturedInput()).toEqual({
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
fields: [
{
key: "q0",
@ -212,7 +212,7 @@ describe("QuestionTool", () => {
expect(capturedInput()).toEqual({
sessionID,
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
fields: [
{
key: "q0",

View file

@ -178,10 +178,12 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* waitForTool(registry, SubagentTool.name)
const progress: ToolRegistry.Progress[] = []
const settled = yield* settleTool(registry, {
sessionID: parent.id,
...toolIdentity,
progress: (update) => Effect.sync(() => progress.push(update)),
call: {
type: "tool-call",
id: "call-subagent",
@ -192,6 +194,7 @@ describe("SubagentTool", () => {
expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText })
const child = yield* sessions.get(outputSessionID(settled.output?.structured))
expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({
parentID: parent.id,
location: parent.location,

View file

@ -10,8 +10,14 @@ import type { Hooks, Transform } from "./registration.js"
export interface Context {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly messageID: SessionMessage.ID
readonly callID: string
readonly progress: (update: Progress) => Effect.Effect<void>
}
export interface Progress {
readonly structured: Readonly<Record<string, unknown>>
readonly content?: ReadonlyArray<Content>
}
export type SchemaType<A> = Schema.Codec<A, any>
@ -253,8 +259,8 @@ export interface ToolExecuteBeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly messageID: SessionMessage.ID
readonly callID: string
input: unknown
}
@ -262,8 +268,8 @@ export interface ToolExecuteAfterEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly messageID: SessionMessage.ID
readonly callID: string
readonly input: unknown
result: ToolResultValue
output?: ToolOutput

View file

@ -5,7 +5,9 @@ import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema, Schema } from "effect"
import type { Hooks, Transform } from "./registration.js"
export type Context = Tool.Context
export type Context = Omit<Tool.Context, "progress"> & {
readonly progress: (update: Tool.Progress) => Promise<void>
}
export type SchemaType<A> = Tool.SchemaType<A>
export type Content = Tool.Content
export type DynamicOutput = Tool.DynamicOutput
@ -50,8 +52,8 @@ export interface ToolExecuteBeforeEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly messageID: SessionMessage.ID
readonly callID: string
input: unknown
}
@ -59,8 +61,8 @@ export interface ToolExecuteAfterEvent {
readonly tool: string
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID
readonly toolCallID: string
readonly messageID: SessionMessage.ID
readonly callID: string
readonly input: unknown
result: Tool.ToolExecuteAfterEvent["result"]
output?: Tool.ToolExecuteAfterEvent["output"]

View file

@ -2499,8 +2499,12 @@ function WebSearch(props: ToolProps) {
function Subagent(props: ToolProps) {
const { navigate } = useRoute()
const data = useData()
const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId))
const description = createMemo(() => stringValue(props.input.description))
const input = createMemo(() => (typeof props.part.state.input === "string" ? {} : props.part.state.input))
const metadata = createMemo(() =>
props.part.state.status === "streaming" ? {} : props.part.state.structured,
)
const sessionID = createMemo(() => stringValue(metadata().sessionID) ?? stringValue(metadata().sessionId))
const description = createMemo(() => stringValue(input().description))
const isRunning = createMemo(() => {
const id = sessionID()
return props.part.state.status === "running" || Boolean(id && data.session.status(id) === "running")
@ -2518,12 +2522,12 @@ function Subagent(props: ToolProps) {
if (id) navigate({ type: "session", sessionID: id })
}}
status={
props.input.background === true || props.metadata.status === "running" ? (
input().background === true || metadata().status === "running" ? (
<StatusBadge>Background</StatusBadge>
) : undefined
}
>
{`${Locale.titlecase(stringValue(props.input.agent) ?? stringValue(props.input.subagent_type) ?? "General")} Subagent — ${description() ?? "Subagent"}`}
{`${Locale.titlecase(stringValue(input().agent) ?? stringValue(input().subagent_type) ?? "General")} Subagent — ${description() ?? "Subagent"}`}
</InlineTool>
)
}

View file

@ -2229,11 +2229,35 @@ test("settles pending tools when a live failure arrives", async () => {
state: { call: true },
},
})
emitEvent(events, {
id: "evt_progress_1",
created: 0,
type: "session.tool.progress",
durable: durable("session-1", 5),
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
callID: "call-1",
structured: { sessionID: "session-child", status: "running" },
content: [],
},
})
await wait(() => {
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
return (
assistant?.type === "assistant" &&
assistant.content[0]?.type === "tool" &&
assistant.content[0].state.status === "running" &&
assistant.content[0].state.structured.sessionID === "session-child"
)
})
emitEvent(events, {
id: "evt_failed_1",
created: 0,
type: "session.tool.failed",
durable: durable("session-1", 5),
durable: durable("session-1", 6),
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
@ -2264,7 +2288,7 @@ test("settles pending tools when a live failure arrives", async () => {
if (tool.state.status !== "error") return
expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
expect(tool.state.input).toEqual({})
expect(tool.state.structured).toEqual({})
expect(tool.state.structured).toEqual({ sessionID: "session-child", status: "running" })
expect(tool.state.content).toEqual([])
expect(tool.executed).toBe(false)
expect(tool.providerState).toEqual({ call: true })