mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
fix(core): close tool output boundary gaps
This commit is contained in:
@@ -40,6 +40,7 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const outputs = yield* ToolOutputStore.Service
|
||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||
@@ -116,7 +117,7 @@ const layer = Layer.effect(
|
||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error>> = []
|
||||
let needsContinuation = false
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
const publisher = createLLMEventPublisher(events, outputs, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||
@@ -165,13 +166,26 @@ const layer = Layer.effect(
|
||||
call: event,
|
||||
progress: (update) =>
|
||||
serialized(
|
||||
events.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID,
|
||||
callID: event.id,
|
||||
structured: { ...update.structured },
|
||||
content: [...update.content],
|
||||
}),
|
||||
Effect.gen(function* () {
|
||||
const bounded = yield* outputs.bound({
|
||||
sessionID: session.id,
|
||||
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"))
|
||||
yield* events.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID,
|
||||
callID: event.id,
|
||||
structured: Object.fromEntries(Object.entries(bounded.output.structured)),
|
||||
content: [...bounded.output.content],
|
||||
})
|
||||
}).pipe(Effect.orDie),
|
||||
),
|
||||
}),
|
||||
).pipe(
|
||||
@@ -500,6 +514,7 @@ export const node = makeLocationNode({
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
ToolOutputStore.node,
|
||||
Database.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AgentV2 } from "../../agent"
|
||||
import { Snapshot } from "../../snapshot"
|
||||
import { RelativePath } from "../../schema"
|
||||
import { SessionUsage } from "../usage"
|
||||
import { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
type Input = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
@@ -34,18 +35,22 @@ const message = (value: unknown) => {
|
||||
}
|
||||
|
||||
type SettledOutput =
|
||||
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
|
||||
| ToolOutput
|
||||
| { readonly error: SessionError.Error }
|
||||
|
||||
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
|
||||
if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
|
||||
const settled = value ?? ToolOutput.fromResultValue(result)
|
||||
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
|
||||
return { structured: record(settled.structured), content: settled.content }
|
||||
return settled
|
||||
}
|
||||
|
||||
/** Persist one step without executing tools or starting a continuation step. */
|
||||
export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish">, input: Input) => {
|
||||
export const createLLMEventPublisher = (
|
||||
events: Pick<EventV2.Interface, "publish">,
|
||||
outputs: Pick<ToolOutputStore.Interface, "bound">,
|
||||
input: Input,
|
||||
) => {
|
||||
const tools = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -421,11 +426,17 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
})
|
||||
return
|
||||
}
|
||||
const bounded = yield* outputs.bound({
|
||||
sessionID: input.sessionID,
|
||||
callID: event.id,
|
||||
output: result,
|
||||
})
|
||||
yield* events.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID: event.id,
|
||||
...result,
|
||||
structured: record(bounded.output.structured),
|
||||
content: bounded.output.content,
|
||||
...(executed ? { result: event.result } : {}),
|
||||
executed,
|
||||
resultState,
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface BoundInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly callID: string
|
||||
readonly output: ToolOutput
|
||||
readonly propagateTruncation?: boolean
|
||||
}
|
||||
|
||||
export interface BoundResult {
|
||||
@@ -173,6 +174,7 @@ const layer = Layer.effect(
|
||||
structured: structuredPath
|
||||
? { _truncated: true, _bytes: encodedBytes, _outputPath: structuredPath }
|
||||
: contextualOverflow &&
|
||||
input.propagateTruncation === true &&
|
||||
Predicate.isObject(input.output.structured) &&
|
||||
"truncated" in input.output.structured &&
|
||||
typeof input.output.structured.truncated === "boolean"
|
||||
|
||||
@@ -49,7 +49,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
) => {
|
||||
const tools: Record<string, Tool.Definition<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
const child = definition(name, registration.tool)
|
||||
const child = definition(name, codeModeTool(registration.tool))
|
||||
const value = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
@@ -96,7 +96,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||
const output = yield* settle(
|
||||
registration.tool,
|
||||
codeModeTool(registration.tool),
|
||||
{ type: "tool-call", id: context.callID, name, input },
|
||||
{
|
||||
sessionID: context.sessionID,
|
||||
@@ -141,6 +141,11 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
})
|
||||
}
|
||||
|
||||
function codeModeTool(tool: AnyTool): AnyTool {
|
||||
if ("jsonSchema" in tool) return tool
|
||||
return { ...tool, structured: undefined }
|
||||
}
|
||||
|
||||
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
if (typeof input !== "object" || Array.isArray(input)) return { input }
|
||||
|
||||
@@ -239,6 +239,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
let line = 1
|
||||
let bytes = 0
|
||||
let next: number | undefined
|
||||
let lineTruncated = false
|
||||
const append = (input: string) => {
|
||||
if (line < offset) {
|
||||
line++
|
||||
@@ -249,6 +250,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
return false
|
||||
}
|
||||
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
|
||||
if (text !== input) lineTruncated = true
|
||||
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
|
||||
if (bytes + size > MAX_READ_BYTES) {
|
||||
next = line
|
||||
@@ -314,7 +316,7 @@ export const read = Effect.fn("ReadTool.read")(function* (
|
||||
content: lines.join("\n"),
|
||||
mime: FSUtil.mimeType(real),
|
||||
offset,
|
||||
truncated: next !== undefined,
|
||||
truncated: next !== undefined || lineTruncated,
|
||||
...(next === undefined ? {} : { next }),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -118,6 +118,8 @@ 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 beforeEvent: ToolHooks.BeforeEvent = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
@@ -169,6 +171,7 @@ const registryLayer = Layer.effect(
|
||||
sessionID: input.sessionID,
|
||||
callID: input.call.id,
|
||||
output: { structured: pending.output.structured, content: yield* normalizeImages(pending.output.content) },
|
||||
propagateTruncation,
|
||||
})
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
settlement =
|
||||
@@ -192,10 +195,21 @@ const registryLayer = Layer.effect(
|
||||
outputPaths: settlement.outputPaths,
|
||||
}
|
||||
yield* toolHooks.runAfter(afterEvent)
|
||||
const finalOutput = afterEvent.output
|
||||
? yield* resources.bound({
|
||||
sessionID: input.sessionID,
|
||||
callID: input.call.id,
|
||||
output: afterEvent.output,
|
||||
propagateTruncation,
|
||||
})
|
||||
: undefined
|
||||
const outputPaths = [
|
||||
...new Set([...(afterEvent.outputPaths ?? []), ...(finalOutput?.outputPaths ?? [])]),
|
||||
]
|
||||
return {
|
||||
result: afterEvent.result,
|
||||
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
||||
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
|
||||
...(finalOutput !== undefined ? { output: finalOutput.output } : {}),
|
||||
...(outputPaths.length > 0 ? { outputPaths } : {}),
|
||||
...(settlement.error !== undefined ? { error: settlement.error } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -32,6 +32,13 @@ export const Output = Schema.Struct({
|
||||
output: Schema.String,
|
||||
})
|
||||
|
||||
const StructuredOutput = Schema.Struct({
|
||||
sessionID: Output.fields.sessionID,
|
||||
status: Output.fields.status,
|
||||
bytes: Schema.Number,
|
||||
truncated: Schema.Boolean,
|
||||
})
|
||||
|
||||
export const description = [
|
||||
"Spawn a subagent: a child session running a configured agent with fresh context.",
|
||||
"Foreground (default) runs the subagent to completion and returns its final response.",
|
||||
@@ -115,6 +122,13 @@ export const Plugin = {
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
structured: StructuredOutput,
|
||||
toStructuredOutput: ({ output }) => ({
|
||||
sessionID: output.sessionID,
|
||||
status: output.status,
|
||||
bytes: Buffer.byteLength(output.output, "utf-8"),
|
||||
truncated: false,
|
||||
}),
|
||||
toModelOutput: ({ output }) => [{ type: "text", text: output.output }],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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" },
|
||||
{
|
||||
|
||||
@@ -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" }])
|
||||
})
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`)
|
||||
|
||||
Reference in New Issue
Block a user