diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 007a53ed70e..9b175a80b4f 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -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, diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 75c8b300afd..53caee351c1 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -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)), + }), + ), }) } diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 6a15137c077..48fcc4d8d60 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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) => diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 1030ff22ffc..690f2cc85ba 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -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 } diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 436a60e7cea..1d79459550d 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -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({ diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index 89672dbfc6c..701b5ff2bb0 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -113,12 +113,13 @@ export const create = (registrations: ReadonlyMap) => { 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) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 3cde812aaa9..f71102c3627 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -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 diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index cdf61866c6d..99e64af50aa 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -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 diff --git a/packages/core/src/tool/hooks.ts b/packages/core/src/tool/hooks.ts index 4b491c57cdb..abe829aa1b4 100644 --- a/packages/core/src/tool/hooks.ts +++ b/packages/core/src/tool/hooks.ts @@ -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 diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 8aad746ed62..0a15ba23509 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -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 diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 4d7c3030b98..8d986790ce6 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -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({ diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 41c9d2dd50e..6b179fe9b8b 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -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), diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index aa2e81b5645..646e09daf6b 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -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 diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index b1087c78228..6e1910ae565 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -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 +} + +export interface Progress { + readonly structured: Readonly> + 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, diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 34583387d78..5109e2a5355 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -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, diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 2589fd08ad9..07d935a1273 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -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 = diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 6fcda996dce..ea4dc87d5ee 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -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. diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 3b900e17842..6a5910c057e 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -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* () { diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index ab65c33a4cf..0e23fc8a32a 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -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 = diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index d7cd8c54f4c..e634411e011 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -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 diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 10d68c7fa38..468f9e3d9ce 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -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) => diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 2fca0e0b955..f6b39e86aa8 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -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", }, }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 6d9e0aac3dc..9ff717d6657 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -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" } }, }) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index afe72f6b59f..2c6fccc892e 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -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: [] }]) }), ) }) diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index a682c1450e7..1591dc4ed47 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -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, diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 1cdc71788a1..1bfbe205577 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -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" }], diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index b7351cd9009..c488c1f7fc9 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -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) }, + ]) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 407d5989a46..5add9239ed4 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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([ diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index 6a1000fec4e..3eedc6fbab1 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -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, }, ), ) diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index a2d132a74bb..4de63dfdcd0 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -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) diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 5d20d01622d..1c91de99660 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -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", diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index e0250065c58..e9b4a5c56e0 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -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, diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index ce53b78bd38..8668570f8fb 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -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 +} + +export interface Progress { + readonly structured: Readonly> + readonly content?: ReadonlyArray } export type SchemaType = Schema.Codec @@ -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 diff --git a/packages/plugin/src/v2/promise/tool.ts b/packages/plugin/src/v2/promise/tool.ts index 77d936f5800..3f85aaae54b 100644 --- a/packages/plugin/src/v2/promise/tool.ts +++ b/packages/plugin/src/v2/promise/tool.ts @@ -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 & { + readonly progress: (update: Tool.Progress) => Promise +} export type SchemaType = Tool.SchemaType 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"] diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 338c3dbf805..7118df30d22 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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" ? ( Background ) : 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"}`} ) } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 0a49e76b215..493c2903256 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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 })