fix(core): preserve bounded tool receipts

This commit is contained in:
Kit Langton 2026-07-20 14:58:58 -04:00
commit f7a72fdf32
22 changed files with 278 additions and 17 deletions

View file

@ -172,17 +172,16 @@ const layer = Layer.effect(
callID: event.id, callID: event.id,
output: update, output: update,
}) })
if ( const structured = bounded.output.structured
typeof bounded.output.structured !== "object" || const entries =
bounded.output.structured === null || typeof structured === "object" && structured !== null && !Array.isArray(structured)
Array.isArray(bounded.output.structured) ? Object.entries(structured)
) : yield* Effect.die(new Error("Tool progress structured output must be an object"))
yield* Effect.die(new Error("Tool progress structured output must be an object"))
yield* events.publish(SessionEvent.Tool.Progress, { yield* events.publish(SessionEvent.Tool.Progress, {
sessionID: session.id, sessionID: session.id,
assistantMessageID, assistantMessageID,
callID: event.id, callID: event.id,
structured: Object.fromEntries(Object.entries(bounded.output.structured)), structured: Object.fromEntries(entries),
content: [...bounded.output.content], content: [...bounded.output.content],
}) })
}).pipe(Effect.orDie), }).pipe(Effect.orDie),
@ -275,6 +274,11 @@ const layer = Layer.effect(
} }
yield* serialized(publisher.failAssistant(error)) yield* serialized(publisher.failAssistant(error))
} }
if (streamFailure instanceof ToolOutputStore.StorageError) {
const error = toSessionError(streamFailure)
yield* serialized(publisher.failUnsettledTools(error))
yield* serialized(publisher.failAssistant(error))
}
// Provider error events only arrive from the stream, so the flag is final here. // Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError() const providerFailed = publisher.hasProviderError()
@ -301,8 +305,13 @@ const layer = Layer.effect(
// settles so the model may recover. A typed infrastructure failure (tool output // settles so the model may recover. A typed infrastructure failure (tool output
// could not be persisted) also fails the assistant and then fails the drain. // could not be persisted) also fails the assistant and then fails the drain.
const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause)) const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
const infraError = const typedError =
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure)) settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
const storageDefect = settledFailure?.reasons.find(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof ToolOutputStore.StorageError,
)
const infraError =
typedError ?? (storageDefect && Cause.isDieReason(storageDefect) ? storageDefect.defect : undefined)
if (settledFailure !== undefined) { if (settledFailure !== undefined) {
const failure = infraError ?? Cause.squash(settledFailure) const failure = infraError ?? Cause.squash(settledFailure)
const error = toSessionError(failure) const error = toSessionError(failure)

View file

@ -410,7 +410,6 @@ export const createLLMEventPublisher = (
if (event.result.type === "error") return if (event.result.type === "error") return
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`)) return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
} }
tool.settled = true
const result = error ? { error } : settledOutput(event.output, event.result) const result = error ? { error } : settledOutput(event.output, event.result)
const executed = event.providerExecuted === true || tool.providerExecuted const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata) const resultState = providerState(event.providerMetadata)
@ -424,12 +423,13 @@ export const createLLMEventPublisher = (
executed, executed,
resultState, resultState,
}) })
tool.settled = true
return return
} }
const bounded = yield* outputs.bound({ const bounded = yield* outputs.bound({
sessionID: input.sessionID, sessionID: input.sessionID,
callID: event.id, callID: event.id,
output: result, output: { ...result, structured: record(result.structured) },
}) })
yield* events.publish(SessionEvent.Tool.Success, { yield* events.publish(SessionEvent.Tool.Success, {
sessionID: input.sessionID, sessionID: input.sessionID,
@ -437,10 +437,12 @@ export const createLLMEventPublisher = (
callID: event.id, callID: event.id,
structured: record(bounded.output.structured), structured: record(bounded.output.structured),
content: bounded.output.content, content: bounded.output.content,
// Provider-executed results remain verbatim for provider-history replay.
...(executed ? { result: event.result } : {}), ...(executed ? { result: event.result } : {}),
executed, executed,
resultState, resultState,
}) })
tool.settled = true
return return
} }
case "tool-error": { case "tool-error": {

View file

@ -16,6 +16,7 @@ import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Tool } from "./tool" import { Tool } from "./tool"
import { ToolStructured } from "./structured"
export const name = "edit" export const name = "edit"
@ -103,6 +104,15 @@ export const Plugin = {
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input, input: Input,
output: Output, output: Output,
structured: Output,
toStructuredOutput: ({ output }) =>
ToolStructured.fit((maximumBytes) => ({
...output,
files: output.files.map((file) => ({
...file,
patch: ToolStructured.truncate(file.patch, maximumBytes),
})),
})),
toModelOutput: ({ input, output }) => [ toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) }, { type: "text", text: toModelOutput(output, input.oldString, input.newString) },
], ],

View file

@ -142,7 +142,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
} }
function codeModeTool(tool: AnyTool): AnyTool { function codeModeTool(tool: AnyTool): AnyTool {
if ("jsonSchema" in tool) return tool if ("jsonSchema" in tool || tool.codeModeOutput !== "output") return tool
return { ...tool, structured: undefined } return { ...tool, structured: undefined }
} }

View file

@ -53,6 +53,7 @@ export const Plugin = {
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput, structured: StructuredOutput,
codeModeOutput: "output",
toStructuredOutput: ({ output }) => ({ count: output.length }), toStructuredOutput: ({ output }) => ({ count: output.length }),
toModelOutput: ({ output }) => [ toModelOutput: ({ output }) => [
{ {

View file

@ -67,6 +67,7 @@ export const Plugin = {
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput, structured: StructuredOutput,
codeModeOutput: "output",
toStructuredOutput: ({ output }) => ({ matches: output.length }), toStructuredOutput: ({ output }) => ({ matches: output.length }),
toModelOutput: ({ output }) => [ toModelOutput: ({ output }) => [
{ {

View file

@ -11,6 +11,7 @@ import { LocationMutation } from "../location-mutation"
import { Patch } from "../patch" import { Patch } from "../patch"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Tool } from "./tool" import { Tool } from "./tool"
import { ToolStructured } from "./structured"
export const name = "patch" export const name = "patch"
@ -72,6 +73,15 @@ export const Plugin = {
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.", "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
input: Input, input: Input,
output: Output, output: Output,
structured: Output,
toStructuredOutput: ({ output }) =>
ToolStructured.fit((maximumBytes) => ({
...output,
files: output.files.map((file) => ({
...file,
patch: ToolStructured.truncate(file.patch, maximumBytes),
})),
})),
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => { execute: (input, context) => {
const applied: Array<typeof Applied.Type> = [] const applied: Array<typeof Applied.Type> = []

View file

@ -7,6 +7,7 @@ import { Form } from "../form"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question" import { QuestionV2 } from "../question"
import { Tool } from "./tool" import { Tool } from "./tool"
import { ToolStructured } from "./structured"
export const name = "question" export const name = "question"
@ -63,6 +64,13 @@ export const Plugin = {
description, description,
input: Input, input: Input,
output: Output, output: Output,
structured: Output,
toStructuredOutput: ({ output }) =>
ToolStructured.fit((maximumBytes) => ({
answers: output.answers.map((answers) =>
answers.map((answer) => ToolStructured.truncate(answer, maximumBytes)),
),
})),
toModelOutput: ({ input, output }) => [ toModelOutput: ({ input, output }) => [
{ type: "text", text: toModelOutput(input.questions, output.answers) }, { type: "text", text: toModelOutput(input.questions, output.answers) },
], ],

View file

@ -74,6 +74,7 @@ export const Plugin = {
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput, structured: StructuredOutput,
codeModeOutput: "output",
toStructuredOutput: ({ output }) => { toStructuredOutput: ({ output }) => {
if ("encoding" in output) if ("encoding" in output)
return { return {

View file

@ -118,8 +118,7 @@ const registryLayer = Layer.effect(
const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) { const settleTool = Effect.fn("ToolRegistry.settleTool")(function* (input: ExecuteInput, tool: AnyTool) {
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool. // Hooks fire only for hosted/local tools; provider-executed calls never reach settleTool.
const propagateTruncation = const propagateTruncation = !("jsonSchema" in tool) && tool.contentTruncation === true
!("jsonSchema" in tool) && tool.structured !== undefined && tool.toStructuredOutput !== undefined
const beforeEvent: ToolHooks.BeforeEvent = { const beforeEvent: ToolHooks.BeforeEvent = {
tool: input.call.name, tool: input.call.name,
sessionID: input.sessionID, sessionID: input.sessionID,
@ -200,7 +199,6 @@ const registryLayer = Layer.effect(
sessionID: input.sessionID, sessionID: input.sessionID,
callID: input.call.id, callID: input.call.id,
output: afterEvent.output, output: afterEvent.output,
propagateTruncation,
}) })
: undefined : undefined
const outputPaths = [ const outputPaths = [

View file

@ -148,6 +148,7 @@ export const Plugin = {
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput, structured: StructuredOutput,
codeModeOutput: "output",
toStructuredOutput: ({ output }) => ({ toStructuredOutput: ({ output }) => ({
truncated: output.truncated, truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }), ...(output.exit === undefined ? {} : { exit: output.exit }),

View file

@ -74,6 +74,8 @@ export const Plugin = {
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput, structured: StructuredOutput,
codeModeOutput: "output",
contentTruncation: true,
toStructuredOutput: ({ output }) => ({ toStructuredOutput: ({ output }) => ({
name: output.name, name: output.name,
directory: output.directory, directory: output.directory,

View file

@ -0,0 +1,39 @@
export * as ToolStructured from "./structured"
import { ToolOutputStore } from "../tool-output-store"
export function fit<A>(project: (maxStringBytes: number) => A) {
const full = project(ToolOutputStore.MAX_STRUCTURED_BYTES)
if (Buffer.byteLength(JSON.stringify(full), "utf-8") <= ToolOutputStore.MAX_STRUCTURED_BYTES) return full
let minimum = 0
let maximum = ToolOutputStore.MAX_STRUCTURED_BYTES
let result = project(0)
while (minimum <= maximum) {
const middle = Math.floor((minimum + maximum) / 2)
const candidate = project(middle)
if (Buffer.byteLength(JSON.stringify(candidate), "utf-8") <= ToolOutputStore.MAX_STRUCTURED_BYTES) {
result = candidate
minimum = middle + 1
continue
}
maximum = middle - 1
}
return result
}
export function truncate(input: string, maximumBytes: number) {
if (Buffer.byteLength(input, "utf-8") <= maximumBytes) return input
const marker = " ... truncated ..."
const markerBytes = Buffer.byteLength(marker, "utf-8")
const available = Math.max(0, maximumBytes - markerBytes)
let bytes = 0
let end = 0
for (const char of input) {
const size = Buffer.byteLength(char, "utf-8")
if (bytes + size > available) break
bytes += size
end += char.length
}
return input.slice(0, end).replace(/\r?\n$/, "") + (maximumBytes >= markerBytes ? marker : "")
}

View file

@ -123,6 +123,7 @@ export const Plugin = {
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput, structured: StructuredOutput,
contentTruncation: true,
toStructuredOutput: ({ output }) => ({ toStructuredOutput: ({ output }) => ({
sessionID: output.sessionID, sessionID: output.sessionID,
status: output.status, status: output.status,

View file

@ -135,6 +135,8 @@ export const Plugin = {
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput, structured: StructuredOutput,
codeModeOutput: "output",
contentTruncation: true,
toStructuredOutput: ({ input, output }) => ({ toStructuredOutput: ({ input, output }) => ({
url: output.url, url: output.url,
contentType: output.contentType, contentType: output.contentType,

View file

@ -209,6 +209,8 @@ export const Plugin = {
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput, structured: StructuredOutput,
codeModeOutput: "output",
contentTruncation: true,
toStructuredOutput: ({ output }) => ({ toStructuredOutput: ({ output }) => ({
provider: output.provider, provider: output.provider,
bytes: Buffer.byteLength(output.text, "utf-8"), bytes: Buffer.byteLength(output.text, "utf-8"),

View file

@ -122,6 +122,45 @@ test("provider-executed success bounds output before durable publication", async
}) })
}) })
test("normalizes structured output to its durable record shape before bounding", async () => {
const guarded: ToolOutputStore.BoundInput[] = []
const outputStore: Pick<ToolOutputStore.Interface, "bound"> = {
bound: (input) => Effect.sync(() => guarded.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
}
const { publisher } = capture("anthropic", outputStore)
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(
publisher.publish(
LLMEvent.toolResult({
...result,
output: { structured: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES - 2), content: [] },
}),
),
)
expect(guarded[0]?.output.structured).toEqual({
value: "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES - 2),
})
})
test("a bounding failure leaves the tool eligible for durable failure", async () => {
const storage = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
const outputStore: Pick<ToolOutputStore.Interface, "bound"> = {
bound: () => Effect.fail(storage),
}
const { published, publisher } = capture("anthropic", outputStore)
await Effect.runPromise(publisher.publish(call))
const exit = await Effect.runPromiseExit(publisher.publish(result))
expect(exit._tag).toBe("Failure")
await Effect.runPromise(publisher.failUnsettledTools({ type: "unknown", message: storage.message }))
expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
expect(published.find((event) => event.type === "session.tool.failed.1")?.data).toMatchObject({
callID: call.id,
error: { type: "unknown", message: storage.message },
})
})
test("provider metadata is flattened using the route key", async () => { test("provider metadata is flattened using the route key", async () => {
const { published, publisher } = capture() const { published, publisher } = capture()
await Effect.runPromise( await Effect.runPromise(

View file

@ -2,7 +2,8 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer, Schema } from "effect"
import { parsePatch } from "diff"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation" import { FileMutation } from "@opencode-ai/core/file-mutation"
@ -174,6 +175,45 @@ describe("EditTool", () => {
), ),
) )
it.live("retains a bounded patch for large edits", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "large.txt")
const before = "x".repeat(9_000)
const after = "y".repeat(9_000)
return Effect.promise(() => fs.writeFile(target, before)).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* settleTool(
registry,
call({ path: "large.txt", oldString: before, newString: after }),
)
const structured = Schema.decodeUnknownSync(EditTool.Output)(settled.output?.structured)
expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
expect(() => parsePatch(structured.files[0]?.patch ?? "")).not.toThrow()
expect(structured).toMatchObject({
replacements: 1,
files: [
{
file: "large.txt",
patch: expect.stringContaining("... truncated ..."),
},
],
})
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("accepts an absolute file path inside the active Location", () => it.live("accepts an absolute file path inside the active Location", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),

View file

@ -100,6 +100,7 @@ test("execute exposes typed internal output instead of the persisted receipt", a
input: Schema.Struct({}), input: Schema.Struct({}),
output: Schema.Struct({ content: Schema.String, bytes: Schema.Number }), output: Schema.Struct({ content: Schema.String, bytes: Schema.Number }),
structured: Schema.Struct({ bytes: Schema.Number }), structured: Schema.Struct({ bytes: Schema.Number }),
codeModeOutput: "output",
toStructuredOutput: ({ output }) => ({ bytes: output.bytes }), toStructuredOutput: ({ output }) => ({ bytes: output.bytes }),
toModelOutput: ({ output }) => [{ type: "text", text: output.content }], toModelOutput: ({ output }) => [{ type: "text", text: output.content }],
execute: () => Effect.succeed({ content: "full payload", bytes: 12 }), execute: () => Effect.succeed({ content: "full payload", bytes: 12 }),
@ -127,3 +128,35 @@ test("execute exposes typed internal output instead of the persisted receipt", a
expect(result.structured).toEqual({ toolCalls: [{ tool: "fetch", status: "completed" }] }) expect(result.structured).toEqual({ toolCalls: [{ tool: "fetch", status: "completed" }] })
expect(result.content).toEqual([{ type: "text", text: "full payload" }]) expect(result.content).toEqual([{ type: "text", text: "full payload" }])
}) })
test("execute keeps the structured projection unless a tool opts into internal output", 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 }),
execute: () => Effect.succeed({ content: "internal", bytes: 8 }),
})
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({})" },
},
{
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.content).toEqual([{ type: "text", text: '{\n "bytes": 8\n}' }])
})

View file

@ -1,7 +1,8 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect"
import { parsePatch } from "diff"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation" import { FileMutation } from "@opencode-ai/core/file-mutation"
@ -217,6 +218,40 @@ describe("PatchTool", () => {
), ),
) )
it.live("retains a bounded patch for large changes", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "large.txt")
const before = "x".repeat(9_000)
const after = "y".repeat(9_000)
return Effect.promise(() => fs.writeFile(target, `${before}\n`)).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* settleTool(
registry,
call(`*** Begin Patch\n*** Update File: large.txt\n@@\n-${before}\n+${after}\n*** End Patch`),
)
const structured = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured)
expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
expect(() => parsePatch(structured.files[0]?.patch ?? "")).not.toThrow()
expect(structured).toMatchObject({
applied: [{ type: "update", resource: "large.txt" }],
files: [{ file: "large.txt", patch: expect.stringContaining("... truncated ...") }],
})
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("rejects moves before applying any hunk", () => it.live("rejects moves before applying any hunk", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),

View file

@ -19,6 +19,7 @@ const assertions: PermissionV2.AssertInput[] = []
let captured: Form.CreateInput | undefined let captured: Form.CreateInput | undefined
let reject = false let reject = false
let deny = false let deny = false
let firstAnswer = "Build"
const capturedInput = () => captured const capturedInput = () => captured
const questionInput = { const questionInput = {
questions: [ questions: [
@ -63,7 +64,7 @@ const form = Layer.succeed(
Effect.andThen( Effect.andThen(
Effect.sync( Effect.sync(
(): Form.TerminalState => (): Form.TerminalState =>
reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build", q1: ["Dev"] } }, reject ? { status: "cancelled" } : { status: "answered", answer: { q0: firstAnswer, q1: ["Dev"] } },
), ),
), ),
), ),
@ -122,6 +123,7 @@ describe("QuestionTool", () => {
captured = undefined captured = undefined
reject = false reject = false
deny = false deny = false
firstAnswer = "Build"
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const questions = [ const questions = [
{ {
@ -200,6 +202,27 @@ describe("QuestionTool", () => {
}), }),
) )
it.effect("retains bounded answers for long custom responses", () =>
Effect.gen(function* () {
reject = false
deny = false
firstAnswer = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
const registry = yield* ToolRegistry.Service
const settled = yield* settleTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-large", name: "question", input: questionInput },
})
expect(Buffer.byteLength(JSON.stringify(settled.output?.structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
expect(settled.output?.structured).toMatchObject({
answers: [[expect.stringContaining("... truncated ...")]],
})
}).pipe(Effect.ensuring(Effect.sync(() => (firstAnswer = "Build")))),
)
it.effect("does not invent tool ownership metadata without a durable registry source", () => it.effect("does not invent tool ownership metadata without a durable registry source", () =>
Effect.gen(function* () { Effect.gen(function* () {
captured = undefined captured = undefined

View file

@ -97,6 +97,10 @@ export type Definition<
readonly input: Input readonly input: Input
readonly output: Output readonly output: Output
readonly structured?: Structured readonly structured?: Structured
/** Expose encoded output to CodeMode instead of the persisted structured projection. */
readonly codeModeOutput?: "output"
/** Mark a receipt's `truncated` field when its model-facing text is bounded. */
readonly contentTruncation?: true
readonly permission?: string readonly permission?: string
readonly toStructuredOutput?: (input: { readonly toStructuredOutput?: (input: {
readonly input: InputValue<Input> readonly input: InputValue<Input>