fix(core): preserve bounded tool receipts
This commit is contained in:
parent
aa1f91e0d0
commit
f7a72fdf32
22 changed files with 278 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue