mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
fix(core): preserve bounded tool receipts
This commit is contained in:
@@ -172,17 +172,16 @@ const layer = Layer.effect(
|
||||
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"))
|
||||
const structured = bounded.output.structured
|
||||
const entries =
|
||||
typeof structured === "object" && structured !== null && !Array.isArray(structured)
|
||||
? Object.entries(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)),
|
||||
structured: Object.fromEntries(entries),
|
||||
content: [...bounded.output.content],
|
||||
})
|
||||
}).pipe(Effect.orDie),
|
||||
@@ -275,6 +274,11 @@ const layer = Layer.effect(
|
||||
}
|
||||
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.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
|
||||
@@ -301,8 +305,13 @@ const layer = Layer.effect(
|
||||
// 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.
|
||||
const settledFailure = settledCauses.find((cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause))
|
||||
const infraError =
|
||||
const typedError =
|
||||
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) {
|
||||
const failure = infraError ?? Cause.squash(settledFailure)
|
||||
const error = toSessionError(failure)
|
||||
|
||||
@@ -410,7 +410,6 @@ export const createLLMEventPublisher = (
|
||||
if (event.result.type === "error") return
|
||||
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
|
||||
}
|
||||
tool.settled = true
|
||||
const result = error ? { error } : settledOutput(event.output, event.result)
|
||||
const executed = event.providerExecuted === true || tool.providerExecuted
|
||||
const resultState = providerState(event.providerMetadata)
|
||||
@@ -424,12 +423,13 @@ export const createLLMEventPublisher = (
|
||||
executed,
|
||||
resultState,
|
||||
})
|
||||
tool.settled = true
|
||||
return
|
||||
}
|
||||
const bounded = yield* outputs.bound({
|
||||
sessionID: input.sessionID,
|
||||
callID: event.id,
|
||||
output: result,
|
||||
output: { ...result, structured: record(result.structured) },
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -437,10 +437,12 @@ export const createLLMEventPublisher = (
|
||||
callID: event.id,
|
||||
structured: record(bounded.output.structured),
|
||||
content: bounded.output.content,
|
||||
// Provider-executed results remain verbatim for provider-history replay.
|
||||
...(executed ? { result: event.result } : {}),
|
||||
executed,
|
||||
resultState,
|
||||
})
|
||||
tool.settled = true
|
||||
return
|
||||
}
|
||||
case "tool-error": {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { ToolStructured } from "./structured"
|
||||
|
||||
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.",
|
||||
input: Input,
|
||||
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 }) => [
|
||||
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
|
||||
],
|
||||
|
||||
@@ -142,7 +142,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
}
|
||||
|
||||
function codeModeTool(tool: AnyTool): AnyTool {
|
||||
if ("jsonSchema" in tool) return tool
|
||||
if ("jsonSchema" in tool || tool.codeModeOutput !== "output") return tool
|
||||
return { ...tool, structured: undefined }
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export const Plugin = {
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
codeModeOutput: "output",
|
||||
toStructuredOutput: ({ output }) => ({ count: output.length }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
|
||||
@@ -67,6 +67,7 @@ export const Plugin = {
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
codeModeOutput: "output",
|
||||
toStructuredOutput: ({ output }) => ({ matches: output.length }),
|
||||
toModelOutput: ({ output }) => [
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LocationMutation } from "../location-mutation"
|
||||
import { Patch } from "../patch"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { ToolStructured } from "./structured"
|
||||
|
||||
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.",
|
||||
input: Input,
|
||||
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) }],
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Form } from "../form"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { Tool } from "./tool"
|
||||
import { ToolStructured } from "./structured"
|
||||
|
||||
export const name = "question"
|
||||
|
||||
@@ -63,6 +64,13 @@ export const Plugin = {
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: Output,
|
||||
toStructuredOutput: ({ output }) =>
|
||||
ToolStructured.fit((maximumBytes) => ({
|
||||
answers: output.answers.map((answers) =>
|
||||
answers.map((answer) => ToolStructured.truncate(answer, maximumBytes)),
|
||||
),
|
||||
})),
|
||||
toModelOutput: ({ input, output }) => [
|
||||
{ type: "text", text: toModelOutput(input.questions, output.answers) },
|
||||
],
|
||||
|
||||
@@ -74,6 +74,7 @@ export const Plugin = {
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
codeModeOutput: "output",
|
||||
toStructuredOutput: ({ output }) => {
|
||||
if ("encoding" in output)
|
||||
return {
|
||||
|
||||
@@ -118,8 +118,7 @@ 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 propagateTruncation = !("jsonSchema" in tool) && tool.contentTruncation === true
|
||||
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
@@ -200,7 +199,6 @@ const registryLayer = Layer.effect(
|
||||
sessionID: input.sessionID,
|
||||
callID: input.call.id,
|
||||
output: afterEvent.output,
|
||||
propagateTruncation,
|
||||
})
|
||||
: undefined
|
||||
const outputPaths = [
|
||||
|
||||
@@ -148,6 +148,7 @@ export const Plugin = {
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
codeModeOutput: "output",
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
truncated: output.truncated,
|
||||
...(output.exit === undefined ? {} : { exit: output.exit }),
|
||||
|
||||
@@ -74,6 +74,8 @@ export const Plugin = {
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
codeModeOutput: "output",
|
||||
contentTruncation: true,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
name: output.name,
|
||||
directory: output.directory,
|
||||
|
||||
@@ -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 : "")
|
||||
}
|
||||
@@ -123,6 +123,7 @@ export const Plugin = {
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
contentTruncation: true,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
sessionID: output.sessionID,
|
||||
status: output.status,
|
||||
|
||||
@@ -135,6 +135,8 @@ export const Plugin = {
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
codeModeOutput: "output",
|
||||
contentTruncation: true,
|
||||
toStructuredOutput: ({ input, output }) => ({
|
||||
url: output.url,
|
||||
contentType: output.contentType,
|
||||
|
||||
@@ -209,6 +209,8 @@ export const Plugin = {
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
codeModeOutput: "output",
|
||||
contentTruncation: true,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
provider: output.provider,
|
||||
bytes: Buffer.byteLength(output.text, "utf-8"),
|
||||
|
||||
@@ -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 () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(
|
||||
|
||||
@@ -2,7 +2,8 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
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 { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
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", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -100,6 +100,7 @@ test("execute exposes typed internal output instead of the persisted receipt", a
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ content: Schema.String, bytes: Schema.Number }),
|
||||
structured: Schema.Struct({ bytes: Schema.Number }),
|
||||
codeModeOutput: "output",
|
||||
toStructuredOutput: ({ output }) => ({ bytes: output.bytes }),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.content }],
|
||||
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.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}' }])
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
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 { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
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", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -19,6 +19,7 @@ const assertions: PermissionV2.AssertInput[] = []
|
||||
let captured: Form.CreateInput | undefined
|
||||
let reject = false
|
||||
let deny = false
|
||||
let firstAnswer = "Build"
|
||||
const capturedInput = () => captured
|
||||
const questionInput = {
|
||||
questions: [
|
||||
@@ -63,7 +64,7 @@ const form = Layer.succeed(
|
||||
Effect.andThen(
|
||||
Effect.sync(
|
||||
(): 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
|
||||
reject = false
|
||||
deny = false
|
||||
firstAnswer = "Build"
|
||||
const registry = yield* ToolRegistry.Service
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
captured = undefined
|
||||
|
||||
@@ -97,6 +97,10 @@ export type Definition<
|
||||
readonly input: Input
|
||||
readonly output: Output
|
||||
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 toStructuredOutput?: (input: {
|
||||
readonly input: InputValue<Input>
|
||||
|
||||
Reference in New Issue
Block a user