From 02807fc2fec1439152caa310dc9765fd35f9fbf2 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Fri, 16 Oct 2026 14:51:30 -0400 Subject: [PATCH 1/2] fix(tool results): keep an RFC-005 reference's uri out of JSON at any depth A tool result's data that isn't a content array of blocks the serializer recognizes was stored, and sent to the agent, as JSON.stringify of the data. An RFC-005 reference in it (a `resource` with a uri, or an `image` or `audio` given by uri) was written with its raw uri, signature and all, wherever it sat: beside a block of another type, under a key, inside another block, or as the whole data. RFC-005 keeps a reference's uri out of the agent's context, since a signed uri is a credential. Inside a content array of recognized blocks, the reference lanes already showed it as its stub. The fallback is now historyJson: JSON of the data in which each value RFC-005 governs is shown as the lanes show it, at any depth. A reference is its stub (an invalid one, such as MCP's embedded resource, the lanes' unrecognized-block line), and inline data that also claims a uri or a disposition is withheld. A uri or data that is null or absent counts as none, so a record that only shares a type name stays data. Everything else is written as JSON writes it, as before: inline media that claims neither, bytes included, and a resource_link's uri among the rest. The same text reaches every caller of the serializer: the stored copy, a script's tool call, the direct Agent API's tool result, a withheld result's kept fields, and the wire. toMembraneToolResult, given no stored copy, wrote a JSON.stringify of its own; it now makes its text with nonErrorResultText, as the stored copy is made. classifyBlock, which every lane consults: - counts any uri beside inline data, a string or any other value but null, as the vector-2 contradiction, so inline data beside an object uri is withheld in every lane rather than shown (and, with the fallback now writing clean inline media as JSON, never written with that uri); - says, for an image or audio block with neither string data nor a string uri, which of the two it has that isn't a string, where it said "neither data nor uri" for those too. The script path's comments say what it fetches (a content array's references, of which an in-process module's is refused for want of a server) and what a script gets. Not in this change: inline media that claims no uri is written as JSON with its bytes wherever the fallback writes it, as on main, and a nested reference's stub is not a save_recent_image slot, as the nested reference wasn't on main. Co-Authored-By: Claude Opus 5.5 --- changelog.d/reference-uri-at-depth.fixed.md | 22 ++ src/framework.ts | 35 ++- src/mcpl/references.ts | 15 +- src/tool-result-history.ts | 62 +++- test/tool-result-reference-uri.test.ts | 308 ++++++++++++++++++++ 5 files changed, 420 insertions(+), 22 deletions(-) create mode 100644 changelog.d/reference-uri-at-depth.fixed.md create mode 100644 test/tool-result-reference-uri.test.ts diff --git a/changelog.d/reference-uri-at-depth.fixed.md b/changelog.d/reference-uri-at-depth.fixed.md new file mode 100644 index 000000000..a680e1f24 --- /dev/null +++ b/changelog.d/reference-uri-at-depth.fixed.md @@ -0,0 +1,22 @@ +- A tool result's data that isn't a content array of blocks agent-framework + recognizes was stored, and sent to the agent, as plain JSON, so an RFC-005 + reference in it carried its raw uri into the agent's context and history. + That JSON now shows, wherever in the data it sits, each reference as its + stub (or as an unrecognized-block line when it's invalid), and inline data + that also claims a uri or a disposition as withheld. A reference is a + `resource` with a uri, at its top level or under `resource` where MCP + embeds one, or an `image` or `audio` block given by `uri`. A uri that is + null or absent counts as none, so a record that only shares one of those + type names is written as before, as is everything else in the data: inline + media that claims neither, with its bytes, and a `resource_link`'s fields + among it. The same text reaches a script that calls the tool, the + direct Agent API's tool result, and a withheld result's kept fields. + Results stored before the upgrade keep the text they were stored with, any + uri in it included, which goes on reaching the model until those results + leave its context. +- Inline `image` or `audio` data beside a uri that isn't a string, such as + an object, is withheld as inline data beside a string uri already was, in + every lane, rather than shown as inline media. An `image` or `audio` block + with neither string data nor a string uri is now said to have data or a + uri that isn't a string when it has one, where the unrecognized-block line + said it had neither. diff --git a/src/framework.ts b/src/framework.ts index 94f1035ff..18735602a 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -14739,8 +14739,13 @@ export class AgentFramework { if (result.data === undefined || result.data === null) return nonErrorResultText(result, 5_000_000); // RFC-005 "eagerly lazy": a script receiving a result is the strongest // available signal that the bytes are wanted (scripts process, they do - // not browse) — materialize every referenced payload now, so the stub - // the script receives carries a workspace path it can open directly. + // not browse) — materialize the references of a content array now, so + // the stub the script receives carries a workspace path it can open + // directly. Only a reference registered against its server can be + // fetched: an MCPL server's result content is, on arrival. An in-process + // module's reference has no server, so its fetch is refused (and logged + // here) and the script gets its stub, as it does for a reference nested + // in other data, which isn't fetched. if (Array.isArray(result.data)) { const fetcher = this.getReferenceFetcher(); for (const block of result.data) { @@ -14753,8 +14758,10 @@ export class AgentFramework { } } // Scripts get (nearly) full data — the script environment IS the spill: - // filtering big results in code is the whole point. 5MB protocol safety - // cap only; no context-cap truncation, no file spill. + // filtering big results in code is the whole point. As in history, a + // reference is its stub at any depth and inline data claiming a uri or a + // disposition is withheld (RFC-005). 5MB protocol safety cap only; no + // context-cap truncation, no file spill. return toolResultDataToHistoryString(result.data, 5_000_000); } @@ -14852,8 +14859,8 @@ export class AgentFramework { } // MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image // blocks natively rather than JSON-stringifying them away. Anything else - // (objects, scalars) falls through to JSON. The error path was handled - // above, so isError is always false on these return paths. + // goes as text. The error path was handled above, so isError is always + // false on these return paths. const blocks = this.tryNativeToolResultContent(afResult.data, maxChars, precomputed?.filePath ?? null); if (blocks) { return { toolUseId: callId, content: blocks, isError: false }; @@ -14861,15 +14868,11 @@ export class AgentFramework { if (precomputed !== undefined) { return { toolUseId: callId, content: precomputed.text, isError: false }; } - // JSON.stringify returns the VALUE undefined (not a string) for undefined - // input — a module tool returning `{ success: true }` with no data would - // otherwise make `content.length` below throw a TypeError mid-turn. - let content = JSON.stringify(afResult.data) ?? ''; - if (maxChars && content.length > maxChars) { - content = safeSlice(content, 0, maxChars) - + '\n\n[truncated — original was ' + content.length + ' chars]'; - } - return { toolUseId: callId, content, isError: false }; + // Without the history path's copy, the text is made as that copy is + // (nonErrorResultText), so a reference's uri stays out of it at any depth + // (RFC-005), a result with no data reads its error text, or no text, and + // never the value undefined. + return { toolUseId: callId, content: nonErrorResultText(afResult, maxChars), isError: false }; } /** @@ -14920,7 +14923,7 @@ export class AgentFramework { if (stub === null) return null; blocks.push({ type: 'text', text: stub }); } else { - return null; // unknown shape — bail to JSON path + return null; // unknown shape: the stored copy's text, which shows any reference in it as its stub } } return hasImage ? blocks : null; diff --git a/src/mcpl/references.ts b/src/mcpl/references.ts index 3d0c1e3d7..15c95321f 100644 --- a/src/mcpl/references.ts +++ b/src/mcpl/references.ts @@ -89,11 +89,12 @@ export function classifyBlock(block: unknown): BlockClassification { const hasUri = typeof block.uri === 'string'; if (hasData) { // Inline form claiming bulk disposition (or smuggling a uri beside - // the data) is the vector-2 contradiction: fail closed, withhold. - return { kind: 'inline', contradiction: block.disposition !== undefined || hasUri }; + // the data, a string or any other value but null) is the vector-2 + // contradiction: fail closed, withhold. + return { kind: 'inline', contradiction: block.disposition !== undefined || (block.uri !== undefined && block.uri !== null) }; } if (hasUri) return parseReferenceFields(block); - return { kind: 'invalid', reason: `${block.type} block with neither data nor uri` }; + return { kind: 'invalid', reason: `${block.type} block ${mediaWithoutStringReason(block)}` }; } case 'resource': return parseReferenceFields(block); @@ -102,6 +103,14 @@ export function classifyBlock(block: unknown): BlockClassification { } } +/** What a media block with neither string data nor a string uri has instead: + * nothing, or a field that isn't a string. */ +function mediaWithoutStringReason(block: Record): string { + const notString = (['data', 'uri'] as const).filter((f) => block[f] !== undefined && block[f] !== null); + if (notString.length === 0) return 'with neither data nor uri'; + return notString.length === 2 ? "whose data and uri aren't strings" : `whose ${notString[0]} isn't a string`; +} + function parseReferenceFields(block: Record): BlockClassification { if (typeof block.uri !== 'string' || block.uri.length === 0) { return { kind: 'invalid', reason: 'reference without string uri' }; diff --git a/src/tool-result-history.ts b/src/tool-result-history.ts index 73ddf431e..e1ec165bb 100644 --- a/src/tool-result-history.ts +++ b/src/tool-result-history.ts @@ -11,7 +11,10 @@ * * For MCP-shaped content arrays, this helper keeps text blocks verbatim and * replaces image blocks with a short `[image: mimeType, ~NKB]` placeholder. - * For anything else it falls back to JSON. + * For anything else it falls back to JSON, written by RFC-005's rules at + * every depth (historyJson): a reference is shown as its stub wherever it + * sits in the data, so its uri never reaches history, and inline data that + * claims a uri or a disposition is withheld. */ import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './mcpl/references.js'; @@ -54,7 +57,7 @@ export function toolResultDataToHistoryString( const fromArray = tryHistoryStringFromContentArray(data, options); // JSON leaves out undefined, a function and a symbol: a result with no data // is stored as no text, as an empty content array is. - const str = fromArray ?? (JSON.stringify(data) as string | undefined) ?? ''; + const str = fromArray ?? historyJson(data) ?? ''; return maxChars ? truncateForHistory(str, maxChars) : str; } @@ -93,7 +96,8 @@ export function truncateForHistory(str: string, maxChars: number): string { /** * If `data` is an MCP content array whose every block has a shape we * recognize, return a history-safe string (images → placeholders). Otherwise - * return `null` to defer to the caller's fallback (JSON, usually). + * return `null` to defer to the fallback, historyJson, which shows a + * reference anywhere in the data as its stub. */ function tryHistoryStringFromContentArray(data: unknown, options?: HistorySerializeOptions): string | null { if (!Array.isArray(data)) return null; @@ -128,6 +132,58 @@ function tryHistoryStringFromContentArray(data: unknown, options?: HistorySerial return parts.join('\n'); } +/** + * Whether RFC-005 governs a value of a tool result's data, wherever it sits. + * A reference is a `resource` with a uri, at its top level or under + * `resource` (where MCP embeds a resource's uri), or an `image` or `audio` + * given by uri; inline data is an `image` or `audio` with data. A uri or + * data that is null or absent counts as none, so a record that only shares + * one of those type names is data. A uri or data of any other value, string + * or not, counts, so a credential fails closed. + */ +function governedByRfc005(b: Record): boolean { + if (b.type === 'resource') return present(b.uri) || (isPlainRecord(b.resource) && present(b.resource.uri)); + if (b.type === 'image' || b.type === 'audio') return present(b.data) || present(b.uri); + return false; +} + +function present(value: unknown): boolean { + return value !== undefined && value !== null; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * A value of a tool result's data as history shows it, by RFC-005's rules, + * wherever it sits in the data. A value RFC-005 governs (governedByRfc005) is + * shown as the reference lanes show it: a reference as its stub, an invalid + * one (such as a uri that isn't a string, or MCP's embedded resource, whose + * uri isn't at its top level) as an unrecognized-block line, and inline data + * that also claims a uri or a disposition as withheld, so no uri is ever + * shown. Inline data that claims neither is itself, as is any other value: + * a block of another type keeps its fields, a `resource_link`'s uri among + * them, since RFC-005 governs references. + */ +function shownInHistory(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + const b = value as Record; + if (!governedByRfc005(b)) return value; + return referenceStubOrNull(b, 'from tool result') ?? value; +} + +/** + * JSON of a tool result's data with each value in it shown as history shows + * it (shownInHistory), at any depth. Data that is itself shown as text, such + * as a bare reference, is that text. + */ +function historyJson(data: unknown): string | undefined { + const root = shownInHistory(data); + if (root !== data && typeof root === 'string') return root; + return JSON.stringify(data, (_key, value: unknown) => shownInHistory(value)) as string | undefined; +} + function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `~${Math.round(bytes / 1024)}KB`; diff --git a/test/tool-result-reference-uri.test.ts b/test/tool-result-reference-uri.test.ts new file mode 100644 index 000000000..8cd0addee --- /dev/null +++ b/test/tool-result-reference-uri.test.ts @@ -0,0 +1,308 @@ +/** + * A tool result's data never reaches the agent, or its history, with an + * RFC-005 reference's uri, wherever in the data it sits: data that isn't a + * content array of blocks the serializer recognizes is written as JSON with + * each reference as its stub and inline data claiming a uri or a disposition + * withheld. Everything else in it is written as JSON writes it, as before. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AgentFramework } from '../src/index.js'; +import type { Module, ModuleContext, ToolCall, ToolResult, ToolDefinition } from '../src/types/index.js'; +import { nonErrorResultText, toolResultDataToHistoryString } from '../src/tool-result-history.js'; +import { parseImagePlaceholders } from '../src/tool-image-ledger.js'; +import { keptWhenWithheld } from '../src/tool-result-guard.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +const SIGNATURE = 'zz-secret-signature'; +const URI = `https://zz-files.example/blob?sig=${SIGNATURE}`; +const RESOURCE = { type: 'resource', uri: URI, mimeType: 'application/pdf', name: 'zz.pdf' }; +const UNKNOWN = { type: 'zz-unknown', note: 'zz-kept' }; +const STUB = /\[ref_[^\]]+\] zz\.pdf — application\/pdf — from tool result/; +const BASE64 = 'aGVsbG8gd29ybGQgaGVsbG8gd29ybGQ='; +const WITHHELD = '[inline content withheld: nonconformant bulk disposition on inline data]'; + +function parsed(text: string): unknown { + return JSON.parse(text); +} + +describe('data the serializer doesn\'t read as content blocks', () => { + it('shows a reference beside a block of another type as its stub, and keeps the rest as JSON', () => { + const text = toolResultDataToHistoryString([UNKNOWN, RESOURCE]); + assert.ok(!text.includes(SIGNATURE), text); + const [unknown, stub] = parsed(text) as [unknown, string]; + assert.deepEqual(unknown, UNKNOWN); + assert.match(stub, STUB); + }); + + it('shows a reference at any depth as its stub', () => { + const wrapped = toolResultDataToHistoryString({ content: [{ type: 'text', text: 'zz-text' }, RESOURCE], page: 2 }); + assert.ok(!wrapped.includes(SIGNATURE), wrapped); + const { content, page } = parsed(wrapped) as { content: [unknown, string]; page: number }; + assert.equal(page, 2); + assert.deepEqual(content[0], { type: 'text', text: 'zz-text' }); + assert.match(content[1], STUB); + const inside = toolResultDataToHistoryString([{ type: 'zz-unknown', attachment: RESOURCE }]); + assert.ok(!inside.includes(SIGNATURE), inside); + assert.match((parsed(inside) as [{ attachment: string }])[0].attachment, STUB); + }); + + it('shows a reference that is the whole data as its stub, as text', () => { + const text = toolResultDataToHistoryString(RESOURCE); + assert.match(text, STUB); + assert.ok(!text.startsWith('"'), text); + assert.ok(!text.includes(SIGNATURE), text); + }); + + it('shows media given by uri as a reference too, an image or audio beside a block of another type', () => { + for (const type of ['image', 'audio']) { + const text = toolResultDataToHistoryString([UNKNOWN, { type, uri: URI, mimeType: `${type}/x-zz`, name: `zz.${type}` }]); + assert.ok(!text.includes(SIGNATURE), text); + assert.match((parsed(text) as [unknown, string])[1], /^\[ref_[^\]]+\] zz\./); + } + }); + + it('withholds what the reference lanes withhold, saying why', () => { + const notAString = parsed(toolResultDataToHistoryString([UNKNOWN, { type: 'resource', uri: { sig: SIGNATURE } }])) as [unknown, string]; + assert.equal(notAString[1], '[unrecognized content block: reference without string uri]'); + const tooLong = parsed(toolResultDataToHistoryString([UNKNOWN, { type: 'resource', uri: `${URI}${'x'.repeat(5000)}` }])) as [unknown, string]; + assert.equal(tooLong[1], '[unrecognized content block: uri exceeds schema maximum]'); + const objectUri = parsed(toolResultDataToHistoryString([UNKNOWN, { type: 'image', uri: { href: URI } }])) as [unknown, string]; + assert.equal(objectUri[1], "[unrecognized content block: image block whose uri isn't a string]"); + const smuggled = toolResultDataToHistoryString([UNKNOWN, { type: 'image', data: BASE64, mimeType: 'image/png', uri: URI }]); + assert.ok(!smuggled.includes(SIGNATURE) && !smuggled.includes(BASE64), smuggled); + assert.deepEqual(parsed(smuggled), [UNKNOWN, WITHHELD]); + const disposition = toolResultDataToHistoryString([UNKNOWN, { type: 'image', data: BASE64, mimeType: 'image/png', disposition: 'ref' }]); + assert.ok(!disposition.includes(BASE64), disposition); + assert.deepEqual(parsed(disposition), [UNKNOWN, WITHHELD]); + }); + + it('withholds inline data beside a uri that is not a string, at any depth and in a content array', () => { + const block = { type: 'image', data: BASE64, mimeType: 'image/png', uri: { href: URI } }; + const nested = toolResultDataToHistoryString({ shot: block, page: 2 }); + assert.ok(!nested.includes(SIGNATURE) && !nested.includes(BASE64), nested); + assert.deepEqual(parsed(nested), { shot: WITHHELD, page: 2 }); + const array = toolResultDataToHistoryString([{ type: 'text', text: 'zz-text' }, block]); + assert.equal(array, `zz-text\n${WITHHELD}`); + // A null uri is none, as everywhere: inline data beside it isn't a claim. + const nullUri = { type: 'audio', data: BASE64, mimeType: 'audio/wav', uri: null }; + assert.equal(toolResultDataToHistoryString({ clip: nullUri }), JSON.stringify({ clip: nullUri })); + }); + + it('writes inline media that claims no uri or disposition as JSON writes it, as before', () => { + // RFC-005 governs references; plain inline bytes are left as they were. + const data = { + shot: { type: 'image', data: BASE64, mimeType: 'image/png' }, + clip: [UNKNOWN, { type: 'audio', data: BASE64, mimeType: 'audio/wav' }], + link: { type: 'image', data: 'https://zz-images.example/a.png', caption: 'zz-caption' }, + }; + assert.equal(toolResultDataToHistoryString(data), JSON.stringify(data)); + assert.equal(toolResultDataToHistoryString(data.shot), JSON.stringify(data.shot)); + }); + + it('writes data with nothing RFC-005 governs exactly as JSON does', () => { + const plain = [{ id: 1, url: URI, type: 'zz-record' }, { type: 'resource_link', uri: URI, name: 'zz' }, 'zz', 7, null]; + assert.equal(toolResultDataToHistoryString(plain), JSON.stringify(plain)); + assert.equal(toolResultDataToHistoryString({ a: 1 }), '{"a":1}'); + }); + + it('writes a record that only shares a type name, with no uri and no data, as JSON does', () => { + // A uri or data that is null or absent is none: nothing here is a reference or bytes. + const records = [ + UNKNOWN, + { type: 'image', uri: null, id: 1, name: 'zz-a' }, + { type: 'audio', data: null, uri: undefined, name: 'zz-b' }, + { type: 'resource', name: 'zz-cpu', value: 0.5 }, + { type: 'resource', uri: undefined, name: 'zz-c' }, + { type: 'resource', resource: { name: 'zz-d' } }, + ]; + assert.equal(toolResultDataToHistoryString(records), JSON.stringify(records)); + assert.equal(toolResultDataToHistoryString({ record: records[1] }), JSON.stringify({ record: records[1] })); + }); + + it("governs MCP's embedded resource, whose uri sits under resource, at any depth", () => { + const embedded = { type: 'resource', resource: { uri: URI, mimeType: 'application/pdf', blob: BASE64 } }; + const line = '[unrecognized content block: reference without string uri]'; + const nested = toolResultDataToHistoryString({ result: { content: [embedded] } }); + assert.ok(!nested.includes(SIGNATURE) && !nested.includes(BASE64), nested); + assert.deepEqual(parsed(nested), { result: { content: [line] } }); + const beside = toolResultDataToHistoryString([UNKNOWN, embedded]); + assert.deepEqual(parsed(beside), [UNKNOWN, line]); + assert.equal(toolResultDataToHistoryString(embedded), line); + // The content-array path shows it the same way. + assert.equal(toolResultDataToHistoryString([{ type: 'text', text: 'zz-text' }, embedded]), `zz-text\n${line}`); + }); + + it('governs media data that is not a string, and a uri that is not a string, failing closed and saying which', () => { + const text = toolResultDataToHistoryString({ + bytes: { type: 'image', data: [1, 2, 3], mimeType: 'image/png' }, + buffer: { type: 'audio', data: { type: 'Buffer', data: [9, 8, 7] } }, + numeric: { type: 'image', uri: 42, name: 'zz-n' }, + both: { type: 'audio', data: [4, 5], uri: 43 }, + }); + assert.ok(!text.includes('1,2,3') && !text.includes('9,8,7') && !text.includes('4,5'), text); + assert.deepEqual(parsed(text), { + bytes: "[unrecognized content block: image block whose data isn't a string]", + buffer: "[unrecognized content block: audio block whose data isn't a string]", + numeric: "[unrecognized content block: image block whose uri isn't a string]", + both: "[unrecognized content block: audio block whose data and uri aren't strings]", + }); + }); + + it("leaves a nested reference's stub out of save_recent_image's inventory, as main left the reference", () => { + // A nested image-typed reference is shown as its stub inside the JSON, + // and nested inline bytes stay JSON: neither is a slot, so no index moves. + const text = toolResultDataToHistoryString([ + UNKNOWN, + { type: 'resource', uri: URI, mimeType: 'image/png', name: 'zz.png' }, + { type: 'image', data: BASE64, mimeType: 'image/png' }, + ]); + assert.ok(!text.includes(SIGNATURE), text); + assert.match((parsed(text) as [unknown, string])[1], /^\[ref_[^\]]+\] zz\.png — image\/png/); + assert.deepEqual(parseImagePlaceholders(text), []); + }); + + it('leaves an array of blocks it recognizes as it was', () => { + assert.match(toolResultDataToHistoryString([{ type: 'text', text: 'zz-text' }, RESOURCE]), /^zz-text\n\[ref_/); + }); + + it('keeps a withheld result\'s kept fields free of the uri', () => { + const kept = keptWhenWithheld({ success: true, data: { files: ['zz.txt'], receipt: [UNKNOWN, RESOURCE] }, keepWhenWithheld: ['files', 'receipt'] }, undefined); + assert.ok(kept !== undefined && !kept.includes(SIGNATURE), kept); + assert.match(kept!, /ref_/); + }); +}); + +/** A module whose one tool answers with the content array it was given. */ +class ContentModule implements Module { + readonly name = 'content'; + constructor(private readonly data: unknown[]) {} + async start(_ctx: ModuleContext): Promise {} + async stop(): Promise {} + getTools(): ToolDefinition[] { + return [{ name: 'get', description: 'Answer with content.', inputSchema: { type: 'object', properties: {} } }]; + } + async handleToolCall(_call: ToolCall): Promise { + return { success: true, data: this.data }; + } + async onProcess(): Promise> { + return {}; + } +} + +/** The content of the tool result the model received for one call, and of what the agent's history stored. */ +async function answerModelCall(data: unknown[]): Promise<{ wire: string; stored: string }> { + const dir = mkdtempSync(join(tmpdir(), 'tool-result-reference-uri-')); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'agent', model: 'test-model', systemPrompt: 'test', allowedTools: 'all' }], + modules: [new ContentModule(data)], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + }); + try { + membrane.pushResponse(createMockResponse([{ type: 'tool_use', id: 'zz-call', name: 'content--get', input: {} }], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'zz-done' }])); + framework.start(); + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'agent', reason: 'external-message', source: 'test', timestamp: Date.now(), + }); + const stream = () => membrane.lastStream as unknown as { receivedToolResults: unknown[][] } | undefined; + const deadline = Date.now() + 5_000; + while ((stream()?.receivedToolResults.length ?? 0) < 1 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 25)); + const received = stream()?.receivedToolResults[0]; + assert.ok(received, 'the model was answered'); + const stored = framework.getAgent('agent')!.getContextManager().getAllMessages() + .flatMap((message) => message.content) + .filter((block) => (block as { type?: string }).type === 'tool_result'); + assert.equal(stored.length, 1, 'the history holds the tool result'); + // Each copy's content alone: the wire's result and the stored block wrap it differently. + const contentOf = (results: unknown[]) => JSON.stringify(results.map((r) => (r as { content?: unknown }).content)); + return { wire: contentOf(received), stored: contentOf(stored) }; + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +} + +describe('the wire without the stored copy', () => { + it('makes its text as the stored copy is made', () => { + const fw = Object.create(AgentFramework.prototype) as { + toMembraneToolResult(callId: string, result: ToolResult, maxChars?: number): { content: unknown; isError: boolean }; + }; + for (const result of [ + { success: true, data: [UNKNOWN, RESOURCE] }, + { success: true, data: { content: [RESOURCE] } }, + { success: false, error: 'zz-failed', isError: false }, + { success: true, data: [{ type: 'text', text: 'zz-text' }] }, + ] as ToolResult[]) { + const { content, isError } = fw.toMembraneToolResult('zz-call', result); + assert.equal(isError, false); + if (result.error === 'zz-failed') assert.equal(content, 'zz-failed'); + const expected = nonErrorResultText(result); + // Each reference gets a fresh id, so compare with the ids taken out. + const ids = (s: string) => s.replace(/ref_[A-Za-z0-9_-]+/g, 'ref_ID'); + assert.equal(ids(content as string), ids(expected)); + assert.ok(!(content as string).includes(SIGNATURE), String(content)); + } + }); +}); + +describe('a model\'s call answered with such data', () => { + it('sends the stored copy, which holds the stub and never the uri', async () => { + const { wire, stored } = await answerModelCall([UNKNOWN, RESOURCE]); + assert.ok(!stored.includes(SIGNATURE), stored); + assert.ok(stored.includes('zz-kept'), stored); + assert.match(stored, /ref_/); + assert.equal(wire, stored); + }); + + it('with an image too, sends the stored copy, which holds the stub and the image as JSON writes it', async () => { + const { wire, stored } = await answerModelCall([{ type: 'image', data: BASE64, mimeType: 'image/png' }, UNKNOWN, RESOURCE]); + assert.ok(!stored.includes(SIGNATURE), stored); + assert.ok(stored.includes(BASE64), stored); + assert.match(stored, /ref_/); + assert.equal(wire, stored); + }); +}); + +describe('a script given such data', () => { + type ScriptFramework = { + dispatchScriptToolCall: (...args: unknown[]) => Promise; + handleScriptToolCall(agentName: string, toolName: string, args: Record): Promise; + }; + + it('gets the text history stores, with each reference as its stub and the rest as JSON writes it', async () => { + const fw = Object.create(AgentFramework.prototype) as ScriptFramework; + const shot = { type: 'image', data: BASE64, mimeType: 'image/png' }; + fw.dispatchScriptToolCall = async () => ({ success: true, data: { file: RESOURCE, shot, page: 2 } }); + const text = await fw.handleScriptToolCall('zz-agent', 'zz_tool', {}); + assert.ok(!text.includes(SIGNATURE), text); + const result = parsed(text) as { file: string; shot: unknown; page: number }; + assert.match(result.file, STUB); + assert.deepEqual(result.shot, shot); + assert.equal(result.page, 2); + }); + + it("gets an in-process module's reference in a content array as its stub, once its fetch is refused", async () => { + const fw = Object.create(AgentFramework.prototype) as ScriptFramework; + fw.dispatchScriptToolCall = async () => ({ success: true, data: [{ type: 'text', text: 'zz-text' }, RESOURCE] }); + const logged: string[] = []; + const error = console.error; + console.error = (...args: unknown[]) => { logged.push(args.map(String).join(' ')); }; + let text: string; + try { + text = await fw.handleScriptToolCall('zz-agent', 'zz_tool', {}); + } finally { + console.error = error; + } + assert.ok(!text.includes(SIGNATURE), text); + assert.match(text, /^zz-text\n\[ref_[^\]]+\] zz\.pdf — application\/pdf — from tool result/); + assert.ok(logged.some((line) => /\[rfc005\] script-demand fetch of ref_\S+ failed: reference has no server binding/.test(line)), logged.join('\n')); + }); +}); From 4ddc200084afb7411fcd8e38edf253eedf224227 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Sat, 17 Oct 2026 00:25:34 -0400 Subject: [PATCH 2/2] fix(tool results): the JSON fallback alone withholds inline data beside a uri that isn't a string, and a module's own media records are written as before From the internal review of 02807fc2. The fallback governed an image or audio record whenever its data was any non-null value, so a module's own record with an array, object or number for data and no uri was replaced by an unrecognized-block line, losing fields such as a url the model needs. Data that isn't a string isn't inline data: such a record is written as JSON, as on main. A uri that is empty counts as none too. classifyBlock had counted a uri of any value beside inline data as the vector-2 contradiction in every lane. The lanes (content arrays, the native wire, the MCPL converters and the render check) never show that uri, so it exposed nothing there, and the change withheld or rejected blocks main showed. They go back to main's rule. The JSON fallback, which writes every field, withholds inline data beside a uri of any non-empty value itself. A withheld result's kept fields were picked out of its data into a new object, so data that is itself a reference lost the type that makes it one, and a kept uri was written as JSON. Such data now keeps its stub. The withheld notice named a bulk disposition for every cause; it now names a uri or a bulk disposition. Tests: module media records with non-string data, and a resource with an empty uri, written as JSON; inline data beside an object uri withheld in the JSON but shown as a placeholder in a content array; an image reference that is the whole data takes a save_recent_image slot, as in a content array; a bare withheld block is the notice, unquoted; a reference that is the whole data keeps its stub as its kept fields; the wire's cut with a cap matches the stored copy's; a media block with neither keeps its line. Co-Authored-By: Claude Opus 5.5 --- changelog.d/reference-uri-at-depth.fixed.md | 36 +++++++----- src/mcpl/references.ts | 7 +-- src/tool-result-guard.ts | 7 ++- src/tool-result-history.ts | 33 ++++++++--- test/tool-result-reference-uri.test.ts | 62 ++++++++++++++++----- 5 files changed, 102 insertions(+), 43 deletions(-) diff --git a/changelog.d/reference-uri-at-depth.fixed.md b/changelog.d/reference-uri-at-depth.fixed.md index a680e1f24..083f5578e 100644 --- a/changelog.d/reference-uri-at-depth.fixed.md +++ b/changelog.d/reference-uri-at-depth.fixed.md @@ -5,18 +5,24 @@ stub (or as an unrecognized-block line when it's invalid), and inline data that also claims a uri or a disposition as withheld. A reference is a `resource` with a uri, at its top level or under `resource` where MCP - embeds one, or an `image` or `audio` block given by `uri`. A uri that is - null or absent counts as none, so a record that only shares one of those - type names is written as before, as is everything else in the data: inline - media that claims neither, with its bytes, and a `resource_link`'s fields - among it. The same text reaches a script that calls the tool, the - direct Agent API's tool result, and a withheld result's kept fields. - Results stored before the upgrade keep the text they were stored with, any - uri in it included, which goes on reaching the model until those results - leave its context. -- Inline `image` or `audio` data beside a uri that isn't a string, such as - an object, is withheld as inline data beside a string uri already was, in - every lane, rather than shown as inline media. An `image` or `audio` block - with neither string data nor a string uri is now said to have data or a - uri that isn't a string when it has one, where the unrecognized-block line - said it had neither. + embeds one, or an `image` or `audio` block given by `uri`; inline data is + an `image` or `audio` block whose data is a string. Since JSON writes every + field, inline data beside a uri of any value is withheld there, not only + beside a string one. A uri that is null, absent or empty counts as none, + and data that isn't a string isn't inline data, so a record that only + shares one of those type names is written as before, as is everything else + in the data: inline media that claims neither, with its bytes, and a + `resource_link`'s fields among it. A reference that is the whole data is + its stub, read as a content array's reference is, a `save_recent_image` + slot included. The same text reaches a script that calls the tool, the + direct Agent API's tool result, and a withheld result's kept fields, where + data that is itself a reference keeps its stub whichever fields its tool + marks. Results stored before the upgrade keep the text they were stored + with, any uri in it included, which goes on reaching the model until those + results leave its context. +- The notice for withheld inline content now says the inline data also + claimed a uri or a bulk disposition. It named only a disposition, also + when a uri was the cause. An `image` or `audio` block with neither string + data nor a string uri is now said to have data or a uri that isn't a + string when it has one, where the unrecognized-block line said it had + neither. diff --git a/src/mcpl/references.ts b/src/mcpl/references.ts index 15c95321f..fce8c2f18 100644 --- a/src/mcpl/references.ts +++ b/src/mcpl/references.ts @@ -89,9 +89,8 @@ export function classifyBlock(block: unknown): BlockClassification { const hasUri = typeof block.uri === 'string'; if (hasData) { // Inline form claiming bulk disposition (or smuggling a uri beside - // the data, a string or any other value but null) is the vector-2 - // contradiction: fail closed, withhold. - return { kind: 'inline', contradiction: block.disposition !== undefined || (block.uri !== undefined && block.uri !== null) }; + // the data) is the vector-2 contradiction: fail closed, withhold. + return { kind: 'inline', contradiction: block.disposition !== undefined || hasUri }; } if (hasUri) return parseReferenceFields(block); return { kind: 'invalid', reason: `${block.type} block ${mediaWithoutStringReason(block)}` }; @@ -149,7 +148,7 @@ export function isInlineContradiction(block: unknown): boolean { } export const INLINE_WITHHELD_TEXT = - '[inline content withheld: nonconformant bulk disposition on inline data]'; + '[inline content withheld: inline data that also claims a uri or a bulk disposition]'; /** * Why a reference counts as expired, or null when it doesn't: its expiresAt diff --git a/src/tool-result-guard.ts b/src/tool-result-guard.ts index 6a901835d..69da57888 100644 --- a/src/tool-result-guard.ts +++ b/src/tool-result-guard.ts @@ -4,7 +4,7 @@ import type { ContextManager, MessageId, MessageMetadata } from '@animalabs/cont import type { ContentBlock, NormalizedMessage, ToolResult } from '@animalabs/membrane'; import type { CompletedToolCall } from './types/index.js'; import { isStateExistsError } from './module-registry.js'; -import { toolResultDataToHistoryString } from './tool-result-history.js'; +import { rfc005TextOf, toolResultDataToHistoryString } from './tool-result-history.js'; import { safeSlice } from './safe-slice.js'; export const TOOL_RESULT_GUARD_NOTICE = 'Tool result withheld by the guard. The tool has already executed.'; @@ -101,7 +101,10 @@ export function keptWhenWithheld(result: CompletedToolCall['result'], maxChars: const fields = data as Record; const kept = Object.fromEntries(keys.filter((key) => Object.hasOwn(fields, key)).map((key) => [key, fields[key]])); if (Object.keys(kept).length === 0) return undefined; - const whole = toolResultDataToHistoryString(kept); + // Data that RFC-005 shows as text, such as a reference's stub, keeps that + // text: picked out of it, a kept uri would lose the type that makes it a + // reference, and be written out of the stub that hides it. + const whole = rfc005TextOf(fields) ?? toolResultDataToHistoryString(kept); if (!maxChars || whole.length <= maxChars) return whole; // Never half a surrogate pair: the stub goes to the provider as text, so // the cut can be one character short of the cap. diff --git a/src/tool-result-history.ts b/src/tool-result-history.ts index e1ec165bb..3f7427bdb 100644 --- a/src/tool-result-history.ts +++ b/src/tool-result-history.ts @@ -136,19 +136,20 @@ function tryHistoryStringFromContentArray(data: unknown, options?: HistorySerial * Whether RFC-005 governs a value of a tool result's data, wherever it sits. * A reference is a `resource` with a uri, at its top level or under * `resource` (where MCP embeds a resource's uri), or an `image` or `audio` - * given by uri; inline data is an `image` or `audio` with data. A uri or - * data that is null or absent counts as none, so a record that only shares - * one of those type names is data. A uri or data of any other value, string - * or not, counts, so a credential fails closed. + * given by uri; inline data is an `image` or `audio` whose data is a string. + * A uri that is null, absent or empty counts as none, and data that isn't a + * string isn't inline data, so a record that only shares one of those type + * names is data. A uri of any other value, string or not, counts, so a + * credential fails closed. */ function governedByRfc005(b: Record): boolean { - if (b.type === 'resource') return present(b.uri) || (isPlainRecord(b.resource) && present(b.resource.uri)); - if (b.type === 'image' || b.type === 'audio') return present(b.data) || present(b.uri); + if (b.type === 'resource') return hasUri(b.uri) || (isPlainRecord(b.resource) && hasUri(b.resource.uri)); + if (b.type === 'image' || b.type === 'audio') return typeof b.data === 'string' || hasUri(b.uri); return false; } -function present(value: unknown): boolean { - return value !== undefined && value !== null; +function hasUri(value: unknown): boolean { + return value !== undefined && value !== null && value !== ''; } function isPlainRecord(value: unknown): value is Record { @@ -170,9 +171,25 @@ function shownInHistory(value: unknown): unknown { if (!value || typeof value !== 'object' || Array.isArray(value)) return value; const b = value as Record; if (!governedByRfc005(b)) return value; + if ((b.type === 'image' || b.type === 'audio') && typeof b.data === 'string') { + // JSON writes every field, so here inline data beside a uri of any value + // is withheld, not only beside a string one as in the lanes, which show + // no uri: an object can carry a credential. An empty uri carries none. + return hasUri(b.uri) || b.disposition !== undefined ? INLINE_WITHHELD_TEXT : value; + } return referenceStubOrNull(b, 'from tool result') ?? value; } +/** + * The text RFC-005 shows a value of a tool result's data as, when it shows it + * as text rather than as its fields: a reference's stub, an invalid one's + * unrecognized-block line, or inline data withheld. Null for any other value. + */ +export function rfc005TextOf(value: unknown): string | null { + const shown = shownInHistory(value); + return shown !== value && typeof shown === 'string' ? shown : null; +} + /** * JSON of a tool result's data with each value in it shown as history shows * it (shownInHistory), at any depth. Data that is itself shown as text, such diff --git a/test/tool-result-reference-uri.test.ts b/test/tool-result-reference-uri.test.ts index 8cd0addee..78a289ae4 100644 --- a/test/tool-result-reference-uri.test.ts +++ b/test/tool-result-reference-uri.test.ts @@ -15,6 +15,7 @@ import type { Module, ModuleContext, ToolCall, ToolResult, ToolDefinition } from import { nonErrorResultText, toolResultDataToHistoryString } from '../src/tool-result-history.js'; import { parseImagePlaceholders } from '../src/tool-image-ledger.js'; import { keptWhenWithheld } from '../src/tool-result-guard.js'; +import { classifyBlock } from '../src/mcpl/references.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; const SIGNATURE = 'zz-secret-signature'; @@ -23,7 +24,7 @@ const RESOURCE = { type: 'resource', uri: URI, mimeType: 'application/pdf', name const UNKNOWN = { type: 'zz-unknown', note: 'zz-kept' }; const STUB = /\[ref_[^\]]+\] zz\.pdf — application\/pdf — from tool result/; const BASE64 = 'aGVsbG8gd29ybGQgaGVsbG8gd29ybGQ='; -const WITHHELD = '[inline content withheld: nonconformant bulk disposition on inline data]'; +const WITHHELD = '[inline content withheld: inline data that also claims a uri or a bulk disposition]'; function parsed(text: string): unknown { return JSON.parse(text); @@ -50,11 +51,20 @@ describe('data the serializer doesn\'t read as content blocks', () => { assert.match((parsed(inside) as [{ attachment: string }])[0].attachment, STUB); }); - it('shows a reference that is the whole data as its stub, as text', () => { + it('shows a reference that is the whole data as its stub, as text, read as a content array\'s reference is', () => { const text = toolResultDataToHistoryString(RESOURCE); assert.match(text, STUB); assert.ok(!text.startsWith('"'), text); assert.ok(!text.includes(SIGNATURE), text); + // An image reference that is the whole data starts its line, so it takes + // a save_recent_image slot, as one in a content array does. + const image = { type: 'image', uri: URI, mimeType: 'image/png', name: 'zz.png' }; + const whole = toolResultDataToHistoryString(image); + assert.match(whole, /^\[ref_[^\]]+\] zz\.png — image\/png/); + assert.equal(parseImagePlaceholders(whole).length, 1); + assert.equal(parseImagePlaceholders(toolResultDataToHistoryString([image])).length, 1); + // Inline data withheld as the whole data is the notice, as text too. + assert.equal(toolResultDataToHistoryString({ type: 'image', data: BASE64, mimeType: 'image/png', uri: URI }), WITHHELD); }); it('shows media given by uri as a reference too, an image or audio beside a block of another type', () => { @@ -80,16 +90,22 @@ describe('data the serializer doesn\'t read as content blocks', () => { assert.deepEqual(parsed(disposition), [UNKNOWN, WITHHELD]); }); - it('withholds inline data beside a uri that is not a string, at any depth and in a content array', () => { + it('withholds inline data beside a uri that is not a string where JSON would write it, and leaves the lanes as they were', () => { const block = { type: 'image', data: BASE64, mimeType: 'image/png', uri: { href: URI } }; const nested = toolResultDataToHistoryString({ shot: block, page: 2 }); assert.ok(!nested.includes(SIGNATURE) && !nested.includes(BASE64), nested); assert.deepEqual(parsed(nested), { shot: WITHHELD, page: 2 }); + // A content array shows no uri, so the image there is a placeholder, as + // it is with no uri at all. const array = toolResultDataToHistoryString([{ type: 'text', text: 'zz-text' }, block]); - assert.equal(array, `zz-text\n${WITHHELD}`); - // A null uri is none, as everywhere: inline data beside it isn't a claim. + assert.equal(array, toolResultDataToHistoryString([{ type: 'text', text: 'zz-text' }, { type: 'image', data: BASE64, mimeType: 'image/png' }])); + assert.ok(!array.includes(SIGNATURE), array); + assert.deepEqual(classifyBlock(block), { kind: 'inline', contradiction: false }); + // A null or empty uri is none: inline data beside it isn't a claim. const nullUri = { type: 'audio', data: BASE64, mimeType: 'audio/wav', uri: null }; assert.equal(toolResultDataToHistoryString({ clip: nullUri }), JSON.stringify({ clip: nullUri })); + const emptyUri = { type: 'image', data: BASE64, mimeType: 'image/png', uri: '' }; + assert.equal(toolResultDataToHistoryString({ shot: emptyUri }), JSON.stringify({ shot: emptyUri })); }); it('writes inline media that claims no uri or disposition as JSON writes it, as before', () => { @@ -109,8 +125,9 @@ describe('data the serializer doesn\'t read as content blocks', () => { assert.equal(toolResultDataToHistoryString({ a: 1 }), '{"a":1}'); }); - it('writes a record that only shares a type name, with no uri and no data, as JSON does', () => { - // A uri or data that is null or absent is none: nothing here is a reference or bytes. + it('writes a record that only shares a type name, with no uri and no string data, as JSON does', () => { + // A uri that is null, absent or empty is none, and data that isn't a + // string isn't inline data: nothing here is a reference or inline bytes. const records = [ UNKNOWN, { type: 'image', uri: null, id: 1, name: 'zz-a' }, @@ -118,6 +135,9 @@ describe('data the serializer doesn\'t read as content blocks', () => { { type: 'resource', name: 'zz-cpu', value: 0.5 }, { type: 'resource', uri: undefined, name: 'zz-c' }, { type: 'resource', resource: { name: 'zz-d' } }, + { type: 'resource', uri: '', text: 'zz-e' }, + { type: 'image', data: [1, 2, 3], mimeType: 'image/png', url: 'https://zz-images.example/a.png' }, + { type: 'audio', data: { type: 'Buffer', data: [9, 8, 7] }, name: 'zz-f', samples: 3 }, ]; assert.equal(toolResultDataToHistoryString(records), JSON.stringify(records)); assert.equal(toolResultDataToHistoryString({ record: records[1] }), JSON.stringify({ record: records[1] })); @@ -136,20 +156,18 @@ describe('data the serializer doesn\'t read as content blocks', () => { assert.equal(toolResultDataToHistoryString([{ type: 'text', text: 'zz-text' }, embedded]), `zz-text\n${line}`); }); - it('governs media data that is not a string, and a uri that is not a string, failing closed and saying which', () => { + it('governs media given a uri that is not a string, failing closed and saying which', () => { const text = toolResultDataToHistoryString({ - bytes: { type: 'image', data: [1, 2, 3], mimeType: 'image/png' }, - buffer: { type: 'audio', data: { type: 'Buffer', data: [9, 8, 7] } }, numeric: { type: 'image', uri: 42, name: 'zz-n' }, both: { type: 'audio', data: [4, 5], uri: 43 }, }); - assert.ok(!text.includes('1,2,3') && !text.includes('9,8,7') && !text.includes('4,5'), text); + assert.ok(!text.includes('4,5'), text); assert.deepEqual(parsed(text), { - bytes: "[unrecognized content block: image block whose data isn't a string]", - buffer: "[unrecognized content block: audio block whose data isn't a string]", numeric: "[unrecognized content block: image block whose uri isn't a string]", both: "[unrecognized content block: audio block whose data and uri aren't strings]", }); + // In the lanes, a media block with neither keeps the line it had. + assert.deepEqual(classifyBlock({ type: 'image' }), { kind: 'invalid', reason: 'image block with neither data nor uri' }); }); it("leaves a nested reference's stub out of save_recent_image's inventory, as main left the reference", () => { @@ -172,7 +190,11 @@ describe('data the serializer doesn\'t read as content blocks', () => { it('keeps a withheld result\'s kept fields free of the uri', () => { const kept = keptWhenWithheld({ success: true, data: { files: ['zz.txt'], receipt: [UNKNOWN, RESOURCE] }, keepWhenWithheld: ['files', 'receipt'] }, undefined); assert.ok(kept !== undefined && !kept.includes(SIGNATURE), kept); - assert.match(kept!, /ref_/); + assert.match((parsed(kept!) as { receipt: [unknown, string] }).receipt[1], STUB); + // Data that is itself a reference keeps its stub, whichever fields are marked. + const whole = keptWhenWithheld({ success: true, data: RESOURCE, keepWhenWithheld: ['uri', 'name'] }, undefined); + assert.ok(whole !== undefined && !whole.includes(SIGNATURE), whole); + assert.match(whole!, STUB); }); }); @@ -251,6 +273,18 @@ describe('the wire without the stored copy', () => { assert.ok(!(content as string).includes(SIGNATURE), String(content)); } }); + + it('cuts its text at the cap as the stored copy is cut', () => { + const fw = Object.create(AgentFramework.prototype) as { + toMembraneToolResult(callId: string, result: ToolResult, maxChars?: number): { content: unknown; isError: boolean }; + }; + const result: ToolResult = { success: true, data: { note: 'zz-'.repeat(200), shot: RESOURCE, more: 'zz-tail' } }; + const { content } = fw.toMembraneToolResult('zz-call', result, 300); + const ids = (s: string) => s.replace(/ref_[A-Za-z0-9_-]+/g, 'ref_ID'); + assert.equal(ids(content as string), ids(nonErrorResultText(result, 300))); + assert.match(content as string, /\n\n\[truncated — original was \d+ chars\]$/); + assert.ok(!(content as string).includes(SIGNATURE), String(content)); + }); }); describe('a model\'s call answered with such data', () => {