fix(core): harden structured output invariants

This commit is contained in:
Kit Langton 2026-07-20 15:24:57 -04:00
commit 98be51b74c
14 changed files with 540 additions and 265 deletions

View file

@ -101,14 +101,17 @@ describe("ToolRegistry", () => {
return Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const text = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
yield* service.register({
structured_only: Tool.make({
description: "Return structured output",
input: Schema.Struct({}),
output: Schema.Struct({ text: Schema.String }),
execute: () => Effect.succeed({ text }),
}),
}, { codemode: false })
yield* service.register(
{
structured_only: Tool.make({
description: "Return structured output",
input: Schema.Struct({}),
output: Schema.Struct({ text: Schema.String }),
execute: () => Effect.succeed({ text }),
}),
},
{ codemode: false },
)
const settled = yield* settleTool(service, {
sessionID,
...identity,
@ -173,6 +176,42 @@ describe("ToolRegistry", () => {
),
)
live.live("allows execute-after hooks to redact output before retention", () =>
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 = "sensitive".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
let observed: unknown
yield* hooks.hook.after((event) => {
observed = event.output?.structured
event.output = { structured: { redacted: true }, content: [{ type: "text", text: "redacted" }] }
})
yield* service.register({ secret: constant(text) }, { codemode: false })
const settled = yield* settleTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-secret", name: "secret", input: { text: "ignored" } },
})
expect(observed).toEqual({ text })
expect(settled).toEqual({
result: { type: "text", value: "redacted" },
output: { structured: { redacted: true }, content: [{ type: "text", text: "redacted" }] },
})
}).pipe(Effect.provide(layer))
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.effect("rejects invalid dotted namespaces", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
@ -202,12 +241,15 @@ describe("ToolRegistry", () => {
it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
}, { codemode: false })
yield* service.register(
{
question: make(),
bash: make(),
edit: make("edit"),
write: make("edit"),
},
{ codemode: false },
)
const names = (permissions: PermissionV2.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
@ -280,14 +322,17 @@ describe("ToolRegistry", () => {
it.effect("returns model errors without swallowing interruption or defects", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
failed: Tool.make({
description: "Failed",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
}),
}, { codemode: false })
yield* service.register(
{
failed: Tool.make({
description: "Failed",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
}),
},
{ codemode: false },
)
expect(
yield* executeTool(service, {
sessionID,
@ -303,14 +348,17 @@ describe("ToolRegistry", () => {
}),
).toEqual({ type: "error", value: "Unknown tool: missing" })
yield* service.register({
defect: Tool.make({
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
}),
}, { codemode: false })
yield* service.register(
{
defect: Tool.make({
description: "Defect",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die("unexpected executor defect"),
}),
},
{ codemode: false },
)
expect(
yield* service.materialize().pipe(
Effect.flatMap((materialized) =>
@ -353,22 +401,23 @@ describe("ToolRegistry", () => {
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
const contexts: Tool.Context[] = []
yield* service.register({
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
}),
}, { codemode: false })
yield* service.register(
{
context: Tool.make({
description: "Context",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
}),
},
{ codemode: false },
)
yield* executeTool(service, {
sessionID,
...identity,
call: { type: "tool-call", id: "call-context", name: "context", input: {} },
})
expect(contexts).toEqual([
{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) },
])
expect(contexts).toEqual([{ sessionID, ...identity, callID: "call-context", progress: expect.any(Function) }])
}),
)
@ -388,27 +437,30 @@ describe("ToolRegistry", () => {
output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] },
outputPaths: ["/managed/generic"],
})
expect(bounds).toHaveLength(2)
expect(bounds).toHaveLength(1)
}),
)
it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
snapshot: Tool.make({
description: "Return images",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
{ type: "text", text: output.text },
],
}),
}, { codemode: false })
yield* service.register(
{
snapshot: Tool.make({
description: "Return images",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }) => Effect.succeed({ text }),
toModelOutput: ({ output }) => [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
{ type: "text", text: output.text },
],
}),
},
{ codemode: false },
)
const settlement = yield* settleTool(service, call("snapshot"))
expect(settlement.output?.content).toEqual([
@ -423,23 +475,26 @@ describe("ToolRegistry", () => {
it.effect("normalizes image progress content before it is published", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
progressive: Tool.make({
description: "Emit image progress",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) =>
context
.progress({
structured: { stage: "capture" },
content: [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
],
})
.pipe(Effect.as({ text })),
}),
}, { codemode: false })
yield* service.register(
{
progressive: Tool.make({
description: "Emit image progress",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
execute: ({ text }, context) =>
context
.progress({
structured: { stage: "capture" },
content: [
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
{ type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
],
})
.pipe(Effect.as({ text })),
}),
},
{ codemode: false },
)
const updates: ToolRegistry.Progress[] = []
yield* settleTool(service, {
@ -471,15 +526,18 @@ describe("ToolRegistry", () => {
encode: SchemaGetter.transform((value) => value === "yes"),
}),
)
yield* service.register({
transformed: Tool.make({
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
}),
}, { codemode: false })
yield* service.register(
{
transformed: Tool.make({
description: "Transform values",
input: Schema.Struct({ value: Transformed }),
output: Schema.Struct({ value: Transformed }),
execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
}),
},
{ codemode: false },
)
expect(
yield* executeTool(service, {
@ -498,25 +556,28 @@ describe("ToolRegistry", () => {
).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
expect(executed).toEqual(["yes"])
yield* service.register({
invalid_output: Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({
value: Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => String(value)),
encode: SchemaGetter.transformOrFail((value) =>
value === "valid"
? Effect.succeed(true)
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
),
}),
),
yield* service.register(
{
invalid_output: Tool.make({
description: "Return invalid output",
input: Schema.Struct({}),
output: Schema.Struct({
value: Schema.Boolean.pipe(
Schema.decodeTo(Schema.String, {
decode: SchemaGetter.transform((value) => String(value)),
encode: SchemaGetter.transformOrFail((value) =>
value === "valid"
? Effect.succeed(true)
: Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
),
}),
),
}),
execute: () => Effect.succeed({ value: "invalid" }),
}),
execute: () => Effect.succeed({ value: "invalid" }),
}),
}, { codemode: false })
},
{ codemode: false },
)
expect(
yield* executeTool(service, {
sessionID,

View file

@ -239,43 +239,46 @@ const permission = Layer.succeed(
)
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>
registry.register({
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: ({ text }, context) =>
Effect.gen(function* () {
authorizations.push(context)
executions.push(text)
activeToolExecutions++
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
yield* Deferred.succeed(toolExecutionsStarted, undefined)
}
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
return { text }
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
}),
defect: Tool.make({
description: "Fail unexpectedly",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe(
Effect.andThen(Effect.die("unexpected tool defect")),
),
}),
// BigInt output with no model content forces ToolOutputStore.bound onto its
// JSON.stringify encode path, which fails with a typed StorageError.
storefail: Tool.make({
description: "Produce output that cannot be persisted",
input: Schema.Struct({}),
output: Schema.Any,
execute: () => Effect.succeed({ big: 1n }),
}),
}, { codemode: false }),
registry.register(
{
echo: Tool.make({
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
execute: ({ text }, context) =>
Effect.gen(function* () {
authorizations.push(context)
executions.push(text)
activeToolExecutions++
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
yield* Deferred.succeed(toolExecutionsStarted, undefined)
}
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
return { text }
}).pipe(Effect.ensuring(Effect.sync(() => activeToolExecutions--))),
}),
defect: Tool.make({
description: "Fail unexpectedly",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
(toolExecutionGate ? Deferred.await(toolExecutionGate) : Effect.void).pipe(
Effect.andThen(Effect.die("unexpected tool defect")),
),
}),
// BigInt output with no model content forces ToolOutputStore.bound onto its
// JSON.stringify encode path, which fails with a typed StorageError.
storefail: Tool.make({
description: "Produce output that cannot be persisted",
input: Schema.Struct({}),
output: Schema.Any,
execute: () => Effect.succeed({ big: 1n }),
}),
},
{ codemode: false },
),
),
)
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
@ -841,19 +844,22 @@ describe("SessionRunnerLLM", () => {
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",
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.gen(function* () {
contexts.push(context)
yield* context.progress({ structured: { phase: progress } })
return { answer: query.toUpperCase() }
}),
}),
}, { codemode: false })
yield* registry.register(
{
location_context: Tool.make({
description: "Read application context",
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ answer: Schema.String }),
execute: ({ query }, context) =>
Effect.gen(function* () {
contexts.push(context)
yield* context.progress({ structured: { phase: progress } })
return { answer: query.toUpperCase() }
}),
}),
},
{ codemode: false },
)
yield* admit(session, "Use application context")
responses = [reply.tool("call-location", "location_context", { query: "hello" }), []]
const events = yield* EventV2.Service
@ -906,14 +912,17 @@ describe("SessionRunnerLLM", () => {
const scope = yield* Scope.make()
const executions: string[] = []
yield* registry
.register({
reloaded: Tool.make({
description: "Record the advertised tool",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
}),
}, { codemode: false })
.register(
{
reloaded: Tool.make({
description: "Record the advertised tool",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("advertised")).pipe(Effect.as({ value: "advertised" })),
}),
},
{ codemode: false },
)
.pipe(Scope.provide(scope))
yield* admit(session, "Use the reloaded tool")
responses = [
@ -931,14 +940,17 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* Scope.close(scope, Exit.void)
yield* registry.register({
reloaded: Tool.make({
description: "Record the replacement tool",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
}),
}, { codemode: false })
yield* registry.register(
{
reloaded: Tool.make({
description: "Record the replacement tool",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.String }),
execute: () => Effect.sync(() => executions.push("replacement")).pipe(Effect.as({ value: "replacement" })),
}),
},
{ codemode: false },
)
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(run)
@ -3374,17 +3386,20 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
blocked: Tool.make({
description: "Fail because policy blocked execution",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
),
}),
}, { codemode: false })
yield* registry.register(
{
blocked: Tool.make({
description: "Fail because policy blocked execution",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "blocked", resources: ["*"] })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
),
}),
},
{ codemode: false },
)
yield* admit(session, "Call blocked")
responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()]
@ -3409,14 +3424,17 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
declined: Tool.make({
description: "Fail because the user declined approval",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new PermissionV2.DeclinedError()),
}),
}, { codemode: false })
yield* registry.register(
{
declined: Tool.make({
description: "Fail because the user declined approval",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new PermissionV2.DeclinedError()),
}),
},
{ codemode: false },
)
yield* admit(session, "Call declined")
response = reply.tool("call-declined", "declined", {})
@ -3446,17 +3464,20 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
corrected: Tool.make({
description: "Fail with user correction feedback",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
),
}),
}, { codemode: false })
yield* registry.register(
{
corrected: Tool.make({
description: "Fail with user correction feedback",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
),
}),
},
{ codemode: false },
)
yield* admit(session, "Call corrected")
responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()]
@ -3512,6 +3533,36 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("does not hide output persistence failure behind another concurrent tool defect", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Call defect and storefail")
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-defect", name: "defect", input: {} }),
LLMEvent.toolCall({ id: "call-storefail", name: "storefail", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call defect and storefail" },
{
type: "assistant",
finish: "error",
error: { type: "unknown", message: expect.stringContaining("Failed to encode tool output") },
},
])
}),
)
it.effect("returns configured permission denials to the model and continues", () =>
Effect.gen(function* () {
const session = yield* setup
@ -3554,14 +3605,17 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
const session = yield* setup
const registry = yield* ToolRegistry.Service
yield* registry.register({
question: Tool.make({
description: "Ask the user",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new QuestionTool.CancelledError()),
}),
}, { codemode: false })
yield* registry.register(
{
question: Tool.make({
description: "Ask the user",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new QuestionTool.CancelledError()),
}),
},
{ codemode: false },
)
yield* admit(session, "Ask then stop")
responses = [reply.tool("call-question", "question", {}), []]

View file

@ -181,8 +181,8 @@ describe("EditTool", () => {
(tmp) => {
reset()
const target = path.join(tmp.path, "large.txt")
const before = "x".repeat(9_000)
const after = "y".repeat(9_000)
const before = "x".repeat(20_000)
const after = "y".repeat(20_000)
return Effect.promise(() => fs.writeFile(target, before)).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
@ -195,7 +195,10 @@ describe("EditTool", () => {
expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
expect(() => parsePatch(structured.files[0]?.patch ?? "")).not.toThrow()
const hunk = parsePatch(structured.files[0]?.patch ?? "")[0]?.hunks[0]
expect(hunk?.lines.some((line) => line.startsWith("-"))).toBe(true)
expect(hunk?.lines.some((line) => line.startsWith("+"))).toBe(true)
expect(hunk).toMatchObject({ oldLines: 2, newLines: 2 })
expect(structured).toMatchObject({
replacements: 1,
files: [

View file

@ -168,6 +168,53 @@ describe("ToolOutputStore", () => {
),
)
it.live("normalizes structured values to their durable JSON record", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const primitive = yield* store.bound({
sessionID,
callID: "call-primitive",
output: { structured: "value", content: [] },
})
const nonFinite = yield* store.bound({
sessionID,
callID: "call-non-finite",
output: { structured: { value: Number.NaN }, content: [] },
})
const omitted = yield* store.bound({
sessionID,
callID: "call-omitted",
output: { structured: undefined, content: [] },
})
expect(primitive.output.structured).toEqual({ value: "value" })
expect(nonFinite.output.structured).toEqual({ value: null })
expect(omitted.output.structured).toEqual({})
}),
),
)
it.live("measures primitive overflow after durable record normalization", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const value = "x".repeat(ToolOutputStore.MAX_STRUCTURED_BYTES)
const encoded = JSON.stringify({ value })
const result = yield* store.bound({
sessionID,
callID: "call-primitive-overflow",
output: { structured: value, content: [] },
})
expect(result.output.structured).toEqual({
_truncated: true,
_bytes: Buffer.byteLength(encoded),
_outputPath: result.outputPaths[0],
})
expect(yield* fs.readFileString(result.outputPaths[0])).toBe(encoded)
}),
),
)
it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
withStore(({ store }) =>
Effect.gen(function* () {

View file

@ -224,8 +224,8 @@ describe("PatchTool", () => {
(tmp) => {
reset()
const target = path.join(tmp.path, "large.txt")
const before = "x".repeat(9_000)
const after = "y".repeat(9_000)
const before = "x".repeat(20_000)
const after = "y".repeat(20_000)
return Effect.promise(() => fs.writeFile(target, `${before}\n`)).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
@ -238,7 +238,10 @@ describe("PatchTool", () => {
expect(Buffer.byteLength(JSON.stringify(structured))).toBeLessThanOrEqual(
ToolOutputStore.MAX_STRUCTURED_BYTES,
)
expect(() => parsePatch(structured.files[0]?.patch ?? "")).not.toThrow()
const hunk = parsePatch(structured.files[0]?.patch ?? "")[0]?.hunks[0]
expect(hunk?.lines.some((line) => line.startsWith("-"))).toBe(true)
expect(hunk?.lines.some((line) => line.startsWith("+"))).toBe(true)
expect(hunk).toMatchObject({ oldLines: 2, newLines: 2 })
expect(structured).toMatchObject({
applied: [{ type: "update", resource: "large.txt" }],
files: [{ file: "large.txt", patch: expect.stringContaining("... truncated ...") }],

View file

@ -223,6 +223,30 @@ describe("ShellTool", () => {
),
)
it.live("marks output truncated when generic settlement limits are lower than shell capture limits", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const settled = yield* settleTool(
registry,
call({ command: overflowCommand(ToolOutputStore.MAX_BYTES + 1_000) }),
)
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true })
expect(settled.output?.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("output truncated; full content saved to"),
})
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("resolves a relative workdir from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@ -439,7 +463,10 @@ describe("ShellTool", () => {
if (update.structured.truncated !== true) return
const content = update.content[0]
if (content?.type !== "text") return
if (content.text.indexOf("\n\n[output truncated; full output saved to:") !== ShellTool.MAX_CAPTURE_BYTES)
if (
content.text.indexOf("\n\n[output truncated; full output saved to:") !==
ShellTool.MAX_CAPTURE_BYTES
)
return
yield* Deferred.succeed(observed, update)
yield* Effect.promise(() => fs.writeFile(releasePath, ""))