feat(plugin): add tool progress reporting

This commit is contained in:
Dax Raad 2026-07-14 15:57:16 -04:00
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, tool: event.tool,
sessionID: event.sessionID, sessionID: event.sessionID,
agent: event.agent, agent: event.agent,
assistantMessageID: event.assistantMessageID, messageID: event.messageID,
toolCallID: event.toolCallID, callID: event.callID,
input: event.input, input: event.input,
} }
return Reflect.apply(callback, undefined, [output]).pipe( 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, tool: event.tool,
sessionID: event.sessionID, sessionID: event.sessionID,
agent: event.agent, agent: event.agent,
assistantMessageID: event.assistantMessageID, messageID: event.messageID,
toolCallID: event.toolCallID, callID: event.callID,
input: event.input, input: event.input,
result: event.result, result: event.result,
output: event.output, output: event.output,

View file

@ -256,10 +256,22 @@ function fromPromiseTool(tool: AnyTool) {
if ("jsonSchema" in tool) if ("jsonSchema" in tool)
return Tool.make({ return Tool.make({
...tool, ...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({ return Tool.make({
...tool, ...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({ toolMaterialization.settle({
sessionID: session.id, sessionID: session.id,
agent: agent.id, agent: agent.id,
assistantMessageID, messageID: assistantMessageID,
call: event, call: event,
progress: (update) =>
serialized(
events.publish(SessionEvent.Tool.Progress, {
sessionID: session.id,
assistantMessageID,
callID: event.id,
structured: { ...update.structured },
content: [...update.content],
}),
),
}), }),
).pipe( ).pipe(
Effect.flatMap((settlement) => Effect.flatMap((settlement) =>

View file

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

View file

@ -122,8 +122,8 @@ export const Plugin = {
return Effect.gen(function* () { return Effect.gen(function* () {
const permissionSource = { const permissionSource = {
type: "tool" as const, type: "tool" as const,
messageID: context.assistantMessageID, messageID: context.messageID,
callID: context.toolCallID, callID: context.callID,
} }
if (input.oldString === input.newString) { if (input.oldString === input.newString) {
return yield* new ToolFailure({ 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 index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
const output = yield* settle( const output = yield* settle(
registration.tool, registration.tool,
{ type: "tool-call", id: context.toolCallID, name, input }, { type: "tool-call", id: context.callID, name, input },
{ {
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
assistantMessageID: context.assistantMessageID, messageID: context.messageID,
toolCallID: context.toolCallID, callID: context.callID,
progress: context.progress,
}, },
).pipe(Effect.mapError((failure) => toolError(failure.message, failure))) ).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
const outputFileParts = outputFiles(output) const outputFileParts = outputFiles(output)

View file

@ -72,7 +72,7 @@ export const Plugin = {
}, },
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, 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 ?? ".") const cwd = path.resolve(location.directory, input.path ?? ".")
yield* fs yield* fs

View file

@ -90,7 +90,7 @@ export const Plugin = {
}, },
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, 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 target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs const info = yield* fs

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,8 +19,14 @@ import { toSessionError } from "../session/to-session-error"
export type ExecuteInput = { export type ExecuteInput = {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly agent: AgentV2.ID readonly agent: AgentV2.ID
readonly assistantMessageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly call: ToolCall 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 { export interface Interface {
@ -65,8 +71,8 @@ const registryLayer = Layer.effect(
tool: input.call.name, tool: input.call.name,
sessionID: input.sessionID, sessionID: input.sessionID,
agent: input.agent, agent: input.agent,
assistantMessageID: input.assistantMessageID, messageID: input.messageID,
toolCallID: input.call.id, callID: input.call.id,
input: input.call.input, input: input.call.input,
} }
yield* toolHooks.runBefore(beforeEvent) yield* toolHooks.runBefore(beforeEvent)
@ -76,8 +82,22 @@ const registryLayer = Layer.effect(
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
agent: input.agent, agent: input.agent,
assistantMessageID: input.assistantMessageID, messageID: input.messageID,
toolCallID: input.call.id, 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( ).pipe(
Effect.map((output) => ({ output })), Effect.map((output) => ({ output })),
@ -94,7 +114,7 @@ const registryLayer = Layer.effect(
} else { } else {
const bounded = yield* resources.bound({ const bounded = yield* resources.bound({
sessionID: input.sessionID, sessionID: input.sessionID,
toolCallID: input.call.id, callID: input.call.id,
output: pending.output, output: pending.output,
}) })
const result = ToolOutput.toResultValue(bounded.output) const result = ToolOutput.toResultValue(bounded.output)
@ -111,8 +131,8 @@ const registryLayer = Layer.effect(
tool: input.call.name, tool: input.call.name,
sessionID: input.sessionID, sessionID: input.sessionID,
agent: input.agent, agent: input.agent,
assistantMessageID: input.assistantMessageID, messageID: input.messageID,
toolCallID: input.call.id, callID: input.call.id,
input: beforeEvent.input, input: beforeEvent.input,
result: settlement.result, result: settlement.result,
output: settlement.output, output: settlement.output,

View file

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

View file

@ -79,7 +79,7 @@ export const Plugin = {
save: [skill.id], save: [skill.id],
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, 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 directory = path.dirname(skill.location)
const files = const files =

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ import { host } from "../plugin/host"
export const toolIdentity = { export const toolIdentity = {
agent: AgentV2.ID.make("build"), 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) => 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, agent: toolIdentity.agent,
source: { source: {
type: "tool", type: "tool",
messageID: toolIdentity.assistantMessageID, messageID: toolIdentity.messageID,
callID: "call_mcp_permission", callID: "call_mcp_permission",
}, },
}) })

View file

@ -363,7 +363,7 @@ describe("PluginV2", () => {
const settlement = yield* materialized.settle({ const settlement = yield* materialized.settle({
sessionID: SessionV2.ID.make("ses_hooks"), sessionID: SessionV2.ID.make("ses_hooks"),
agent: AgentV2.ID.make("build"), 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" } }, 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 plugins = yield* PluginV2.Service
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const host = yield* PluginHost.make(plugins) const host = yield* PluginHost.make(plugins)
const progress: ToolRegistry.Progress[] = []
const promisePlugin = Plugin.define({ const promisePlugin = Plugin.define({
id: "promise-tool", id: "promise-tool",
setup: async (ctx) => { setup: async (ctx) => {
@ -139,7 +140,10 @@ describe("fromPromise", () => {
description: "Hello", description: "Hello",
input: Schema.Struct({ name: Schema.String }), input: Schema.Struct({ name: Schema.String }),
output: 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({ yield* materialized.settle({
sessionID: SessionV2.ID.make("ses_promise_tool"), sessionID: SessionV2.ID.make("ses_promise_tool"),
agent: AgentV2.ID.make("build"), 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" } }, call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
}), }),
).toMatchObject({ result: { type: "text", value: "Hello, 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 = { const identity = {
agent: AgentV2.ID.make("build"), 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 => ({ const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({
sessionID, 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", () => { test("success event data can carry a provider-executed result", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({ const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID, sessionID,
assistantMessageID: SessionMessage.ID.create(), messageID: SessionMessage.ID.create(),
callID: "call-old", callID: "call-old",
structured: { type: "media", mime: "image/png" }, structured: { type: "media", mime: "image/png" },
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, 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 retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore = Layer.mock(ToolOutputStore.Service, { const outputStore = Layer.mock(ToolOutputStore.Service, {
bound: (input) => { 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( return Effect.sync(() => bounds.push(input)).pipe(
Effect.as( Effect.as(
input.toolCallID === "call-bounded" input.callID === "call-bounded"
? { ? {
output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] }, output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] },
outputPaths: ["/managed/generic"], outputPaths: ["/managed/generic"],
@ -32,7 +32,7 @@ const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.
const it = testEffect(registryLayer) const it = testEffect(registryLayer)
const identity = { const identity = {
agent: AgentV2.ID.make("build"), 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 sessionID = SessionV2.ID.make("ses_registry")
const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({ const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({
@ -240,7 +240,9 @@ describe("ToolRegistry", () => {
...identity, ...identity,
call: { type: "tool-call", id: "call-context", name: "context", input: {} }, 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 }), input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }), output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) => execute: ({ query }, context) =>
Effect.sync(() => { Effect.gen(function* () {
contexts.push(context) contexts.push(context)
yield* context.progress({ structured: { phase: "reading" } })
return { answer: query.toUpperCase() } return { answer: query.toUpperCase() }
}), }),
}), }),
}, { codemode: false }) }, { codemode: false })
yield* admit(session, "Use application context") yield* admit(session, "Use application context")
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []] 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) yield* session.resume(sessionID)
@ -799,10 +807,12 @@ describe("SessionRunnerLLM", () => {
{ {
sessionID, sessionID,
agent: AgentV2.ID.make("build"), agent: AgentV2.ID.make("build"),
assistantMessageID: expect.stringMatching(/^msg_/), messageID: expect.stringMatching(/^msg_/),
toolCallID: "call-location", 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([ expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use application context" }, { type: "user", text: "Use application context" },
{ {
@ -2252,7 +2262,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2) expect(requests).toHaveLength(2)
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) 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"]) expect(executions).toEqual(["hello"])
const context = yield* session.context(sessionID) const context = yield* session.context(sessionID)
expect(context).toMatchObject([ 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"), sessionID: Session.ID.make("ses_execute"),
agent: Agent.ID.make("build"), agent: Agent.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_execute"), messageID: SessionMessage.ID.make("msg_execute"),
toolCallID: "call_execute", callID: "call_execute",
progress: () => Effect.void,
}, },
), ),
) )

View file

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

View file

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

View file

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

View file

@ -10,8 +10,14 @@ import type { Hooks, Transform } from "./registration.js"
export interface Context { export interface Context {
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly toolCallID: string 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> export type SchemaType<A> = Schema.Codec<A, any>
@ -253,8 +259,8 @@ export interface ToolExecuteBeforeEvent {
readonly tool: string readonly tool: string
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly toolCallID: string readonly callID: string
input: unknown input: unknown
} }
@ -262,8 +268,8 @@ export interface ToolExecuteAfterEvent {
readonly tool: string readonly tool: string
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly toolCallID: string readonly callID: string
readonly input: unknown readonly input: unknown
result: ToolResultValue result: ToolResultValue
output?: ToolOutput 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 { JsonSchema, Schema } from "effect"
import type { Hooks, Transform } from "./registration.js" 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 SchemaType<A> = Tool.SchemaType<A>
export type Content = Tool.Content export type Content = Tool.Content
export type DynamicOutput = Tool.DynamicOutput export type DynamicOutput = Tool.DynamicOutput
@ -50,8 +52,8 @@ export interface ToolExecuteBeforeEvent {
readonly tool: string readonly tool: string
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly toolCallID: string readonly callID: string
input: unknown input: unknown
} }
@ -59,8 +61,8 @@ export interface ToolExecuteAfterEvent {
readonly tool: string readonly tool: string
readonly sessionID: Session.ID readonly sessionID: Session.ID
readonly agent: Agent.ID readonly agent: Agent.ID
readonly assistantMessageID: SessionMessage.ID readonly messageID: SessionMessage.ID
readonly toolCallID: string readonly callID: string
readonly input: unknown readonly input: unknown
result: Tool.ToolExecuteAfterEvent["result"] result: Tool.ToolExecuteAfterEvent["result"]
output?: Tool.ToolExecuteAfterEvent["output"] output?: Tool.ToolExecuteAfterEvent["output"]

View file

@ -2499,8 +2499,12 @@ function WebSearch(props: ToolProps) {
function Subagent(props: ToolProps) { function Subagent(props: ToolProps) {
const { navigate } = useRoute() const { navigate } = useRoute()
const data = useData() const data = useData()
const sessionID = createMemo(() => stringValue(props.metadata.sessionID) ?? stringValue(props.metadata.sessionId)) const input = createMemo(() => (typeof props.part.state.input === "string" ? {} : props.part.state.input))
const description = createMemo(() => stringValue(props.input.description)) 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 isRunning = createMemo(() => {
const id = sessionID() const id = sessionID()
return props.part.state.status === "running" || Boolean(id && data.session.status(id) === "running") 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 }) if (id) navigate({ type: "session", sessionID: id })
}} }}
status={ status={
props.input.background === true || props.metadata.status === "running" ? ( input().background === true || metadata().status === "running" ? (
<StatusBadge>Background</StatusBadge> <StatusBadge>Background</StatusBadge>
) : undefined ) : 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> </InlineTool>
) )
} }

View file

@ -2229,11 +2229,35 @@ test("settles pending tools when a live failure arrives", async () => {
state: { call: true }, 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, { emitEvent(events, {
id: "evt_failed_1", id: "evt_failed_1",
created: 0, created: 0,
type: "session.tool.failed", type: "session.tool.failed",
durable: durable("session-1", 5), durable: durable("session-1", 6),
data: { data: {
sessionID: "session-1", sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9", 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 if (tool.state.status !== "error") return
expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" }) expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
expect(tool.state.input).toEqual({}) 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.state.content).toEqual([])
expect(tool.executed).toBe(false) expect(tool.executed).toBe(false)
expect(tool.providerState).toEqual({ call: true }) expect(tool.providerState).toEqual({ call: true })