fix(core): close tool output boundary gaps

This commit is contained in:
Kit Langton 2026-07-20 14:34:50 -04:00
commit aa1f91e0d0
14 changed files with 293 additions and 27 deletions

View file

@ -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<ToolOutputStore.Interface, "bound"> = {
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<EventV2.Interface, "publish"> = {
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<ToolOutputStore.Interface, "bound"> = {
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(

View file

@ -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)
}),
)

View file

@ -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" },
{

View file

@ -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" }])
})

View file

@ -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* () {

View file

@ -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

View file

@ -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(`<subagent id="${childID}" state="completed"`)