From aa1f91e0d0edd0d70dca6d1e767922089dac5dd7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 20 Jul 2026 14:34:50 -0400 Subject: [PATCH] fix(core): close tool output boundary gaps --- packages/core/src/session/runner/llm.ts | 31 +++++++--- .../src/session/runner/publish-llm-event.ts | 19 ++++-- packages/core/src/tool-output-store.ts | 2 + packages/core/src/tool/execute.ts | 9 ++- packages/core/src/tool/read-filesystem.ts | 4 +- packages/core/src/tool/registry.ts | 18 +++++- packages/core/src/tool/subagent.ts | 14 +++++ .../test/session-runner-tool-events.test.ts | 34 +++++++++- .../test/session-runner-tool-registry.test.ts | 45 +++++++++++++- packages/core/test/session-runner.test.ts | 11 +++- packages/core/test/tool-execute.test.ts | 34 ++++++++++ packages/core/test/tool-output-store.test.ts | 62 ++++++++++++++++++- .../core/test/tool-read-filesystem.test.ts | 15 +++++ packages/core/test/tool-subagent.test.ts | 22 +++++-- 14 files changed, 293 insertions(+), 27 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index dfb5de0478..3f78db090f 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -40,6 +40,7 @@ const layer = Layer.effect( const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service const title = yield* SessionTitle.Service + const outputs = yield* ToolOutputStore.Service // Title generation is a side effect of the first step; it must not delay step continuation. // Tracked per process so repeated wakes before the second user message arrives don't // re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history. @@ -116,7 +117,7 @@ const layer = Layer.effect( const ownedToolFibers: Array> = [] let needsContinuation = false const startSnapshot = yield* snapshots.capture() - const publisher = createLLMEventPublisher(events, { + const publisher = createLLMEventPublisher(events, outputs, { sessionID: session.id, agent: agent.id, // The selected catalog identity, not model.id: route-level ids are provider API @@ -165,13 +166,26 @@ const layer = Layer.effect( call: event, progress: (update) => serialized( - events.publish(SessionEvent.Tool.Progress, { - sessionID: session.id, - assistantMessageID, - callID: event.id, - structured: { ...update.structured }, - content: [...update.content], - }), + Effect.gen(function* () { + const bounded = yield* outputs.bound({ + sessionID: session.id, + callID: event.id, + output: update, + }) + if ( + typeof bounded.output.structured !== "object" || + bounded.output.structured === null || + Array.isArray(bounded.output.structured) + ) + yield* Effect.die(new Error("Tool progress structured output must be an object")) + yield* events.publish(SessionEvent.Tool.Progress, { + sessionID: session.id, + assistantMessageID, + callID: event.id, + structured: Object.fromEntries(Object.entries(bounded.output.structured)), + content: [...bounded.output.content], + }) + }).pipe(Effect.orDie), ), }), ).pipe( @@ -500,6 +514,7 @@ export const node = makeLocationNode({ SessionCompaction.node, SessionTitle.node, Snapshot.node, + ToolOutputStore.node, Database.node, ], }) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 1344d2627a..0762ca85ad 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -11,6 +11,7 @@ import { AgentV2 } from "../../agent" import { Snapshot } from "../../snapshot" import { RelativePath } from "../../schema" import { SessionUsage } from "../usage" +import { ToolOutputStore } from "../../tool-output-store" type Input = { readonly sessionID: SessionSchema.ID @@ -34,18 +35,22 @@ const message = (value: unknown) => { } type SettledOutput = - | { readonly structured: Record; readonly content: ToolOutput["content"] } + | ToolOutput | { readonly error: SessionError.Error } const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => { if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } } const settled = value ?? ToolOutput.fromResultValue(result) if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`) - return { structured: record(settled.structured), content: settled.content } + return settled } /** Persist one step without executing tools or starting a continuation step. */ -export const createLLMEventPublisher = (events: Pick, input: Input) => { +export const createLLMEventPublisher = ( + events: Pick, + outputs: Pick, + input: Input, +) => { const tools = new Map< string, { @@ -421,11 +426,17 @@ export const createLLMEventPublisher = (events: Pick) => { ) => { const tools: Record> = {} for (const [name, registration] of registrations) { - const child = definition(name, registration.tool) + const child = definition(name, codeModeTool(registration.tool)) const value = Tool.make({ description: child.description, input: child.inputSchema, @@ -96,7 +96,7 @@ export const create = (registrations: ReadonlyMap) => { Effect.gen(function* () { const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1) const output = yield* settle( - registration.tool, + codeModeTool(registration.tool), { type: "tool-call", id: context.callID, name, input }, { sessionID: context.sessionID, @@ -141,6 +141,11 @@ export const create = (registrations: ReadonlyMap) => { }) } +function codeModeTool(tool: AnyTool): AnyTool { + if ("jsonSchema" in tool) return tool + return { ...tool, structured: undefined } +} + function displayInput(input: unknown): Record | undefined { if (input === null || input === undefined) return if (typeof input !== "object" || Array.isArray(input)) return { input } diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts index e325a83edd..275f84fd3e 100644 --- a/packages/core/src/tool/read-filesystem.ts +++ b/packages/core/src/tool/read-filesystem.ts @@ -239,6 +239,7 @@ export const read = Effect.fn("ReadTool.read")(function* ( let line = 1 let bytes = 0 let next: number | undefined + let lineTruncated = false const append = (input: string) => { if (line < offset) { line++ @@ -249,6 +250,7 @@ export const read = Effect.fn("ReadTool.read")(function* ( return false } const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input + if (text !== input) lineTruncated = true const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) if (bytes + size > MAX_READ_BYTES) { next = line @@ -314,7 +316,7 @@ export const read = Effect.fn("ReadTool.read")(function* ( content: lines.join("\n"), mime: FSUtil.mimeType(real), offset, - truncated: next !== undefined, + truncated: next !== undefined || lineTruncated, ...(next === undefined ? {} : { next }), }) }), diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index ac003775f4..77dc539b83 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -118,6 +118,8 @@ const registryLayer = Layer.effect( const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) { // Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool. + const propagateTruncation = + !("jsonSchema" in tool) && tool.structured !== undefined && tool.toStructuredOutput !== undefined const beforeEvent: ToolHooks.BeforeEvent = { tool: input.call.name, sessionID: input.sessionID, @@ -169,6 +171,7 @@ const registryLayer = Layer.effect( sessionID: input.sessionID, callID: input.call.id, output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) }, + propagateTruncation, }) const result = ToolOutput.toResultValue(bounded.output) settlement = @@ -192,10 +195,21 @@ const registryLayer = Layer.effect( outputPaths: settlement.outputPaths, } yield* toolHooks.runAfter(afterEvent) + const finalOutput = afterEvent.output + ? yield* resources.bound({ + sessionID: input.sessionID, + callID: input.call.id, + output: afterEvent.output, + propagateTruncation, + }) + : undefined + const outputPaths = [ + ...new Set([...(afterEvent.outputPaths ?? []), ...(finalOutput?.outputPaths ?? [])]), + ] return { result: afterEvent.result, - ...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}), - ...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}), + ...(finalOutput !== undefined ? { output: finalOutput.output } : {}), + ...(outputPaths.length > 0 ? { outputPaths } : {}), ...(settlement.error !== undefined ? { error: settlement.error } : {}), } }) diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 1e459a674e..5f53b0e95f 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -32,6 +32,13 @@ export const Output = Schema.Struct({ output: Schema.String, }) +const StructuredOutput = Schema.Struct({ + sessionID: Output.fields.sessionID, + status: Output.fields.status, + bytes: Schema.Number, + truncated: Schema.Boolean, +}) + export const description = [ "Spawn a subagent: a child session running a configured agent with fresh context.", "Foreground (default) runs the subagent to completion and returns its final response.", @@ -115,6 +122,13 @@ export const Plugin = { description, input: Input, output: Output, + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ + sessionID: output.sessionID, + status: output.status, + bytes: Buffer.byteLength(output.output, "utf-8"), + truncated: false, + }), toModelOutput: ({ output }) => [{ type: "text", text: output.output }], execute: (input, context) => Effect.gen(function* () { diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index b6f44dccdb..736af419e2 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -12,11 +12,15 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { RelativePath } from "@opencode-ai/core/schema" import { Snapshot } from "@opencode-ai/core/snapshot" import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" const sessionID = SessionV2.ID.make("ses_tool_event_test") const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" +const outputs: Pick = { + bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }), +} -const capture = (providerMetadataKey = "anthropic") => { +const capture = (providerMetadataKey = "anthropic", outputStore = outputs) => { const published: Array<{ readonly type: string; readonly data: unknown }> = [] const events: Pick = { publish: (definition, data) => @@ -33,7 +37,7 @@ const capture = (providerMetadataKey = "anthropic") => { } return { published, - publisher: createLLMEventPublisher(events, { + publisher: createLLMEventPublisher(events, outputStore, { sessionID, agent: AgentV2.ID.make("build"), model: { @@ -92,6 +96,32 @@ test("provider-executed success retains its raw provider result", async () => { expect(success?.data).toHaveProperty("result") }) +test("provider-executed success bounds output before durable publication", async () => { + const guarded: ToolOutputStore.BoundInput[] = [] + const outputStore: Pick = { + bound: (input) => + Effect.sync(() => guarded.push(input)).pipe( + Effect.as({ + output: { + structured: { _truncated: true, _bytes: 20_000, _outputPath: "/managed/provider" }, + content: [{ type: "text" as const, text: "bounded provider output" }], + }, + outputPaths: ["/managed/provider"], + }), + ), + } + const { published, publisher } = capture("anthropic", outputStore) + await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true }))) + await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true }))) + + expect(guarded).toHaveLength(1) + expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({ + structured: { _truncated: true, _bytes: 20_000, _outputPath: "/managed/provider" }, + content: [{ type: "text", text: "bounded provider output" }], + result: result.result, + }) +}) + test("provider metadata is flattened using the route key", async () => { const { published, publisher } = capture() await Effect.runPromise( diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 8e486f02d3..8e9060820e 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -3,11 +3,13 @@ import { Tool } from "@opencode-ai/core/tool/tool" import { AgentV2 } from "@opencode-ai/core/agent" import type { PermissionV2 } from "@opencode-ai/core/permission" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { Image } from "@opencode-ai/core/image" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { ToolHooks } from "@opencode-ai/core/tool/hooks" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { executeTool, settleTool, toolDefinitions } from "./lib/tool" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" @@ -130,6 +132,47 @@ describe("ToolRegistry", () => { ), ) + live.live("bounds output replaced by an execute-after hook", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + const layer = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolHooks.node]), [ + [Global.node, Global.layerWith({ data: tmp.path })], + [Image.node, imageStore], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]) + return Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const hooks = yield* ToolHooks.Service + const text = "y".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES) + yield* hooks.hook.after((event) => { + event.result = { type: "text", value: "hook result" } + event.output = { structured: { text }, content: [] } + }) + yield* service.register({ hooked: make() }, { codemode: false }) + const settled = yield* settleTool(service, { + sessionID, + ...identity, + call: { type: "tool-call", id: "call-hooked", name: "hooked", input: { text: "original" } }, + }) + const encoded = JSON.stringify({ text }) + + expect(settled.result).toEqual({ type: "text", value: "hook result" }) + expect(settled.outputPaths).toHaveLength(1) + expect(settled.output).toEqual({ + structured: { + _truncated: true, + _bytes: Buffer.byteLength(encoded), + _outputPath: settled.outputPaths?.[0], + }, + content: [{ type: "text", text: encoded }], + }) + }).pipe(Effect.provide(layer)) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.effect("rejects invalid dotted namespaces", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service @@ -345,7 +388,7 @@ describe("ToolRegistry", () => { output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] }, outputPaths: ["/managed/generic"], }) - expect(bounds).toHaveLength(1) + expect(bounds).toHaveLength(2) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index e3297a54a1..b42b5c2053 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -840,6 +840,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const registry = yield* ToolRegistry.Service const contexts: Tool.Context[] = [] + const progress = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES) yield* registry.register({ location_context: Tool.make({ description: "Read application context", @@ -848,7 +849,7 @@ describe("SessionRunnerLLM", () => { execute: ({ query }, context) => Effect.gen(function* () { contexts.push(context) - yield* context.progress({ structured: { phase: "reading" } }) + yield* context.progress({ structured: { phase: progress } }) return { answer: query.toUpperCase() } }), }), @@ -875,7 +876,13 @@ describe("SessionRunnerLLM", () => { progress: expect.any(Function), }, ]) - expect(Array.from(yield* Fiber.join(progressFiber))[0]?.data.structured).toEqual({ phase: "reading" }) + const update = Array.from(yield* Fiber.join(progressFiber))[0]?.data + expect(update?.structured).toMatchObject({ + _truncated: true, + _bytes: Buffer.byteLength(JSON.stringify({ phase: progress })), + _outputPath: expect.any(String), + }) + expect(update?.content).toEqual([{ type: "text", text: JSON.stringify({ phase: progress }) }]) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Use application context" }, { diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index 66254ec408..074ea3b907 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -93,3 +93,37 @@ test("execute supports callable namespace tools", async () => { }) expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }]) }) + +test("execute exposes typed internal output instead of the persisted receipt", async () => { + const child = Tool.make({ + description: "Fetch content", + input: Schema.Struct({}), + output: Schema.Struct({ content: Schema.String, bytes: Schema.Number }), + structured: Schema.Struct({ bytes: Schema.Number }), + toStructuredOutput: ({ output }) => ({ bytes: output.bytes }), + toModelOutput: ({ output }) => [{ type: "text", text: output.content }], + execute: () => Effect.succeed({ content: "full payload", bytes: 12 }), + }) + const execute = ExecuteTool.create(new Map([["fetch", { tool: child, name: "fetch" }]])) + const result = await Effect.runPromise( + Tool.settle( + execute, + { + type: "tool-call", + id: "call_execute", + name: "execute", + input: { code: "return (await tools.fetch({})).content" }, + }, + { + sessionID: Session.ID.make("ses_execute"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_execute"), + callID: "call_execute", + progress: () => Effect.void, + }, + ), + ) + + expect(result.structured).toEqual({ toolCalls: [{ tool: "fetch", status: "completed" }] }) + expect(result.content).toEqual([{ type: "text", text: "full payload" }]) +}) diff --git a/packages/core/test/tool-output-store.test.ts b/packages/core/test/tool-output-store.test.ts index 6256b8bed6..2193ad1cfe 100644 --- a/packages/core/test/tool-output-store.test.ts +++ b/packages/core/test/tool-output-store.test.ts @@ -80,6 +80,9 @@ describe("ToolOutputStore", () => { _bytes: Buffer.byteLength(JSON.stringify(structured)), _outputPath: result.outputPaths[0], }) + expect(Buffer.byteLength(JSON.stringify(result.output.structured))).toBeLessThanOrEqual( + ToolOutputStore.MAX_STRUCTURED_BYTES, + ) expect(result.outputPaths).toHaveLength(1) expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured) expect(result.output.content).toHaveLength(1) @@ -107,6 +110,46 @@ describe("ToolOutputStore", () => { ), ) + it.live("measures the structured boundary in UTF-8 bytes", () => + withStore(({ store }) => + Effect.gen(function* () { + const structured = { text: "é".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES / 2) } + const encoded = JSON.stringify(structured) + expect(structured.text.length).toBeLessThan(ToolOutputStore.MAX_STRUCTURED_BYTES) + expect(Buffer.byteLength(encoded)).toBeGreaterThan(ToolOutputStore.MAX_STRUCTURED_BYTES) + + const result = yield* store.bound({ + sessionID, + callID: "call-structured-utf8", + output: { structured, content: [] }, + }) + + expect(result.output.structured).toMatchObject({ + _truncated: true, + _bytes: Buffer.byteLength(encoded), + }) + }), + ), + ) + + it.live("retains independent structured and contextual overflow", () => + withStore(({ store, fs }) => + Effect.gen(function* () { + const structured = { detail: "s".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES) } + const content = "c".repeat(ToolOutputStore.MAX_BYTES + 1) + const result = yield* store.bound({ + sessionID, + callID: "call-two-channel-overflow", + output: { structured, content: [{ type: "text", text: content }] }, + }) + + expect(result.outputPaths).toHaveLength(2) + expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured) + expect(yield* fs.readFileString(result.outputPaths[1])).toBe(content) + }), + ), + ) + it.live("keeps structured output at the receipt boundary inline", () => withStore(({ store }) => Effect.gen(function* () { @@ -173,12 +216,13 @@ describe("ToolOutputStore", () => { ), ) - it.live("marks receipt metadata truncated when bounding its contextual output", () => + it.live("marks projected receipt metadata truncated when bounding its contextual output", () => withStore(({ store }) => Effect.gen(function* () { const result = yield* store.bound({ sessionID, callID: "call-receipt", + propagateTruncation: true, output: { structured: { bytes: ToolOutputStore.MAX_BYTES + 1, truncated: false }, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }], @@ -189,6 +233,22 @@ describe("ToolOutputStore", () => { ), ) + it.live("preserves ordinary truncated metadata when bounding contextual output", () => + withStore(({ store }) => + Effect.gen(function* () { + const result = yield* store.bound({ + sessionID, + callID: "call-ordinary-truncated", + output: { + structured: { truncated: false }, + content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }], + }, + }) + expect(result.output.structured).toEqual({ truncated: false }) + }), + ), + ) + it.live("bounds structured data duplicated in projected text independently", () => withStore(({ store, fs }) => Effect.gen(function* () { diff --git a/packages/core/test/tool-read-filesystem.test.ts b/packages/core/test/tool-read-filesystem.test.ts index e9970fb975..9895b9882a 100644 --- a/packages/core/test/tool-read-filesystem.test.ts +++ b/packages/core/test/tool-read-filesystem.test.ts @@ -100,6 +100,21 @@ describe("ReadToolFileSystem", () => { }), ) + it.effect("reports a shortened long line as truncated", () => + Effect.gen(function* () { + const { fs, files, directory } = yield* fixture + const file = path.join(directory, "long-line.txt") + yield* files.writeFileString(file, "x".repeat(3_000)) + + const result = yield* ReadToolFileSystem.read(fs, file, "long-line.txt", { limit: 1 }) + + expect(result).toMatchObject({ type: "text-page", truncated: true }) + if (!(result instanceof ReadToolFileSystem.TextPage)) throw new Error("expected text page") + expect(result.next).toBeUndefined() + expect(result.content).toEndWith("... (line truncated to 2000 chars)") + }), + ) + it.effect("preserves the media ingestion limit message", () => Effect.gen(function* () { const { fs, files, directory } = yield* fixture diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index fc257a7d40..e28b31ae19 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -35,7 +35,8 @@ const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.ID.make("test") }) const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } -const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(SubagentTool.Output)(value).sessionID +const outputSessionID = (value: unknown) => + Schema.decodeUnknownSync(Schema.Struct({ sessionID: SessionV2.ID }))(value).sessionID const executionNode = makeGlobalNode({ service: SessionExecution.Service, @@ -229,7 +230,12 @@ describe("SubagentTool", () => { }, }) - expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) + expect(settled.output?.structured).toMatchObject({ + status: "completed", + bytes: Buffer.byteLength(childText), + truncated: false, + }) + expect(settled.output?.structured).not.toHaveProperty("output") expect((yield* sessions.get(outputSessionID(settled.output?.structured))).parentID).toBe(parent.id) }), ), @@ -264,7 +270,13 @@ describe("SubagentTool", () => { }, }) - expect(settled.output?.structured).toMatchObject({ status: "completed", output: childText }) + expect(settled.result).toEqual({ type: "text", value: childText }) + expect(settled.output?.structured).toMatchObject({ + status: "completed", + bytes: Buffer.byteLength(childText), + truncated: false, + }) + expect(settled.output?.structured).not.toHaveProperty("output") const child = yield* sessions.get(outputSessionID(settled.output?.structured)) expect(progress[0]?.structured).toEqual({ sessionID: child.id, status: "running" }) expect(child).toMatchObject({ @@ -358,8 +370,10 @@ describe("SubagentTool", () => { const childID = outputSessionID(settled.output?.structured) expect(settled.output?.structured).toMatchObject({ status: "running", - output: expect.stringContaining(`id: ${childID}`), + bytes: expect.any(Number), + truncated: false, }) + expect(settled.output?.structured).not.toHaveProperty("output") const admission = Array.from(yield* Fiber.join(admitted))[0] expect(admission?.data.input.data.text).toContain(`