diff --git a/src/framework.ts b/src/framework.ts index 9b3c1bc..36b97f6 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1,4 +1,5 @@ import { join } from 'node:path'; +import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './mcpl/references.js'; import { appendFileSync, mkdirSync } from 'node:fs'; import { JsStore } from '@animalabs/chronicle'; import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane'; @@ -7925,6 +7926,11 @@ export class AgentFramework { for (const raw of data) { if (!raw || typeof raw !== 'object') return null; const b = raw as { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown }; + if (isInlineContradiction(b)) { + // RFC-005 vector 2: withhold inline data claiming bulk disposition. + blocks.push({ type: 'text', text: INLINE_WITHHELD_TEXT }); + continue; + } if (b.type === 'text' && typeof b.text === 'string') { let text = b.text; if (maxChars && text.length > maxChars) { @@ -7940,6 +7946,13 @@ export class AgentFramework { type: 'image', source: { type: 'base64', data: b.data, mediaType: b.mimeType }, }); + } else if (b.type === 'resource' || ((b.type === 'image' || b.type === 'audio') && typeof (b as { uri?: unknown }).uri === 'string')) { + // RFC-005 reference block → bounded stub. Previously this bailed the + // whole array to JSON, which both inlined the reference verbatim and + // demoted any adjacent image block to stringified base64. + const stub = referenceStubOrNull(b, 'from tool result'); + if (stub === null) return null; + blocks.push({ type: 'text', text: stub }); } else { return null; // unknown shape — bail to JSON path } diff --git a/src/mcpl/channel-registry.ts b/src/mcpl/channel-registry.ts index ebfd537..e7c2b59 100644 --- a/src/mcpl/channel-registry.ts +++ b/src/mcpl/channel-registry.ts @@ -14,6 +14,7 @@ */ import type { ContentBlock } from '@animalabs/membrane'; +import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './references.js'; import type { JsStore } from '@animalabs/chronicle'; import type { @@ -124,6 +125,16 @@ function convertBlock(block: McplContentBlock): ContentBlock { return { type: 'text', text: block.text }; case 'image': + if (isInlineContradiction(block)) { + // RFC-005 vector 2: inline data claiming bulk disposition — fail + // closed, withhold the data (checked BEFORE the data branch). + return { type: 'text', text: INLINE_WITHHELD_TEXT }; + } + if (block.uri && block.disposition) { + // RFC-005 uri-form media with a disposition claim: stub, do not + // hand the URI to the provider or inline it. + return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' }; + } if (block.data && block.mimeType) { return { type: 'image', @@ -139,6 +150,16 @@ function convertBlock(block: McplContentBlock): ContentBlock { return { type: 'text', text: '[Image: no data]' }; case 'audio': + if (isInlineContradiction(block)) { + // RFC-005 vector 2: inline data claiming bulk disposition — fail + // closed, withhold the data (checked BEFORE the data branch). + return { type: 'text', text: INLINE_WITHHELD_TEXT }; + } + if (block.uri && block.disposition) { + // RFC-005 uri-form media with a disposition claim: stub, do not + // hand the URI to the provider or inline it. + return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' }; + } if (block.data && block.mimeType) { return { type: 'audio', @@ -148,7 +169,14 @@ function convertBlock(block: McplContentBlock): ContentBlock { return { type: 'text', text: '[Audio: no data]' }; case 'resource': - return { type: 'text', text: `[Resource: ${block.uri}]` }; + // RFC-005: reference blocks become bounded stubs — never raw URIs + // (a signed URL is a bearer credential that looks like a location). + return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' }; + + default: + // Unknown wire block types previously fell off the exhaustive switch + // and propagated `undefined` into ContentBlock[]. Fail visibly. + return { type: 'text', text: `[unrecognized content block: ${(block as { type?: string }).type ?? 'untyped'}]` }; } } diff --git a/src/mcpl/hook-orchestrator.ts b/src/mcpl/hook-orchestrator.ts index 3726c5e..14c4e85 100644 --- a/src/mcpl/hook-orchestrator.ts +++ b/src/mcpl/hook-orchestrator.ts @@ -14,6 +14,7 @@ */ import type { ContentBlock } from '@animalabs/membrane'; +import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './references.js'; import type { ContextInjection } from '@animalabs/context-manager'; import type { @@ -65,6 +66,16 @@ function convertBlock(block: McplContentBlock): ContentBlock { return { type: 'text', text: block.text }; case 'image': + if (isInlineContradiction(block)) { + // RFC-005 vector 2: inline data claiming bulk disposition — fail + // closed, withhold the data (checked BEFORE the data branch). + return { type: 'text', text: INLINE_WITHHELD_TEXT }; + } + if (block.uri && block.disposition) { + // RFC-005 uri-form media with a disposition claim: stub, do not + // hand the URI to the provider or inline it. + return { type: 'text', text: referenceStubOrNull(block, 'attachment on context injection') ?? '[reference]' }; + } if (block.data && block.mimeType) { return { type: 'image', @@ -81,6 +92,16 @@ function convertBlock(block: McplContentBlock): ContentBlock { return { type: 'text', text: '[Image: missing data]' }; case 'audio': + if (isInlineContradiction(block)) { + // RFC-005 vector 2: inline data claiming bulk disposition — fail + // closed, withhold the data (checked BEFORE the data branch). + return { type: 'text', text: INLINE_WITHHELD_TEXT }; + } + if (block.uri && block.disposition) { + // RFC-005 uri-form media with a disposition claim: stub, do not + // hand the URI to the provider or inline it. + return { type: 'text', text: referenceStubOrNull(block, 'attachment on context injection') ?? '[reference]' }; + } if (block.data && block.mimeType) { return { type: 'audio', @@ -91,8 +112,14 @@ function convertBlock(block: McplContentBlock): ContentBlock { return { type: 'text', text: `[Audio: ${block.uri ?? 'missing data'}]` }; case 'resource': - // Resources don't have a direct membrane equivalent — degrade to text - return { type: 'text', text: `[Resource: ${block.uri}]` }; + // RFC-005: reference blocks become bounded stubs — never raw URIs + // (a signed URL is a bearer credential that looks like a location). + return { type: 'text', text: referenceStubOrNull(block, 'attachment on context injection') ?? '[reference]' }; + + default: + // Unknown wire block types previously fell off the exhaustive switch + // and propagated `undefined` into ContentBlock[]. Fail visibly. + return { type: 'text', text: `[unrecognized content block: ${(block as { type?: string }).type ?? 'untyped'}]` }; } } diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index 291e477..a9cde38 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -8,6 +8,7 @@ */ import type { ContentBlock } from '@animalabs/membrane'; +import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './references.js'; import type { McplContentBlock, @@ -51,12 +52,22 @@ export interface McplPushEvent { * Convert a single MCPL wire-format content block to a membrane ContentBlock. * Same logic as hook-orchestrator.ts. */ -function convertBlock(block: McplContentBlock): ContentBlock { +export function convertBlock(block: McplContentBlock): ContentBlock { switch (block.type) { case 'text': return { type: 'text', text: block.text }; case 'image': + if (isInlineContradiction(block)) { + // RFC-005 vector 2: inline data claiming bulk disposition — fail + // closed, withhold the data (checked BEFORE the data branch). + return { type: 'text', text: INLINE_WITHHELD_TEXT }; + } + if (block.uri && block.disposition) { + // RFC-005 uri-form media with a disposition claim: stub, do not + // hand the URI to the provider or inline it. + return { type: 'text', text: referenceStubOrNull(block, 'attachment on push event') ?? '[reference]' }; + } if (block.data && block.mimeType) { return { type: 'image', @@ -72,6 +83,16 @@ function convertBlock(block: McplContentBlock): ContentBlock { return { type: 'text', text: '[Image: no data]' }; case 'audio': + if (isInlineContradiction(block)) { + // RFC-005 vector 2: inline data claiming bulk disposition — fail + // closed, withhold the data (checked BEFORE the data branch). + return { type: 'text', text: INLINE_WITHHELD_TEXT }; + } + if (block.uri && block.disposition) { + // RFC-005 uri-form media with a disposition claim: stub, do not + // hand the URI to the provider or inline it. + return { type: 'text', text: referenceStubOrNull(block, 'attachment on push event') ?? '[reference]' }; + } if (block.data && block.mimeType) { return { type: 'audio', @@ -81,7 +102,14 @@ function convertBlock(block: McplContentBlock): ContentBlock { return { type: 'text', text: '[Audio: no data]' }; case 'resource': - return { type: 'text', text: `[Resource: ${block.uri}]` }; + // RFC-005: reference blocks become bounded stubs — never raw URIs + // (a signed URL is a bearer credential that looks like a location). + return { type: 'text', text: referenceStubOrNull(block, 'attachment on push event') ?? '[reference]' }; + + default: + // Unknown wire block types previously fell off the exhaustive switch + // and propagated `undefined` into ContentBlock[]. Fail visibly. + return { type: 'text', text: `[unrecognized content block: ${(block as { type?: string }).type ?? 'untyped'}]` }; } } diff --git a/src/mcpl/references.ts b/src/mcpl/references.ts new file mode 100644 index 0000000..d241802 --- /dev/null +++ b/src/mcpl/references.ts @@ -0,0 +1,264 @@ +/** + * RFC-005 bulk content references — host treatment (agent-framework side). + * + * Mirrors the pure module in mcpl-core-ts `src/references.ts` (this framework + * deliberately does not depend on that package; hand-maintained MCPL types + * are the house convention — see types.ts header). Semantics are the RFC's, + * revision 3: + * + * - every parsed field is server *testimony*, never a fact (§3); + * - the one invalid-field rule (§8): a bad optional field is dropped while + * the block and its subtractive `disposition` survive; a bad `uri` + * rejects the block whole; + * - `disposition:"never"` withholds payload AND uri from model context + * unconditionally — enforced in code here, not by caller discipline (§5); + * - stub size is independent of every server-supplied field length (§5); + * - unparseable `expiresAt` fails closed (§7.4); + * - unknown block types never propagate `undefined` (the pre-existing + * convertBlock switches had no default — a latent corruption this module + * retires). + * + * The ReferenceRegistry is the host-private reference record (§6.2): raw + * URIs and testimony live here and never in model-visible content. Fetching + * (§6/§7 origin binding, ceilings, redirects) is deliberately not here yet — + * the registry is what makes a future host-mediated fetcher and the + * code-execution materialization hook possible. + */ + +export const REFERENCE_LIMITS = { uri: 4096, mimeType: 255, name: 255, expiresAt: 64 } as const; +export const DIGEST_PATTERN = /^sha256:[A-Za-z0-9_-]{43}$/; +const MAX_SAFE = 9007199254740991; // 2^53 - 1 +const STUB_FIELD_CHARS = 120; + +export type ReferenceDisposition = 'never' | 'ref'; + +export interface ReferenceTestimony { + uri: string; + mimeType?: string; + sizeBytes?: number; + digest?: string; + expiresAt?: string; + name?: string; + disposition?: ReferenceDisposition; + rejectedFields: string[]; + truncatedFields: string[]; +} + +export type BlockClassification = + | { kind: 'text' } + | { kind: 'inline'; contradiction: boolean } + | { kind: 'reference'; testimony: ReferenceTestimony } + | { kind: 'invalid'; reason: string }; + +function isRecord(x: unknown): x is Record { + return typeof x === 'object' && x !== null && !Array.isArray(x); +} + +function takeString( + raw: unknown, field: keyof typeof REFERENCE_LIMITS & string, + out: { rejected: string[]; truncated: string[] }, +): string | undefined { + if (raw === undefined) return undefined; + if (typeof raw !== 'string') { out.rejected.push(field); return undefined; } + const limit = REFERENCE_LIMITS[field]; + if (raw.length > limit) { out.truncated.push(field); return raw.slice(0, limit); } + return raw; +} + +export function classifyBlock(block: unknown): BlockClassification { + if (!isRecord(block) || typeof block.type !== 'string') { + return { kind: 'invalid', reason: 'not a content block' }; + } + switch (block.type) { + case 'text': + return typeof block.text === 'string' + ? { kind: 'text' } + : { kind: 'invalid', reason: 'text block without string text' }; + case 'image': + case 'audio': { + const hasData = typeof block.data === 'string'; + 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 }; + } + if (hasUri) return parseReferenceFields(block); + return { kind: 'invalid', reason: `${block.type} block with neither data nor uri` }; + } + case 'resource': + return parseReferenceFields(block); + default: + return { kind: 'invalid', reason: `unknown block type: ${block.type}` }; + } +} + +function parseReferenceFields(block: Record): BlockClassification { + if (typeof block.uri !== 'string' || block.uri.length === 0) { + return { kind: 'invalid', reason: 'reference without string uri' }; + } + if (block.uri.length > REFERENCE_LIMITS.uri) { + return { kind: 'invalid', reason: 'uri exceeds schema maximum' }; + } + const out = { rejected: [] as string[], truncated: [] as string[] }; + const t: ReferenceTestimony = { uri: block.uri, rejectedFields: out.rejected, truncatedFields: out.truncated }; + t.mimeType = takeString(block.mimeType, 'mimeType', out); + t.name = takeString(block.name, 'name', out); + t.expiresAt = takeString(block.expiresAt, 'expiresAt', out); + if (block.sizeBytes !== undefined) { + const n = block.sizeBytes; + if (typeof n === 'number' && Number.isInteger(n) && n >= 0 && n <= MAX_SAFE) t.sizeBytes = n; + else out.rejected.push('sizeBytes'); + } + if (block.digest !== undefined) { + if (typeof block.digest === 'string' && DIGEST_PATTERN.test(block.digest)) t.digest = block.digest; + else out.rejected.push('digest'); + } + if (block.disposition !== undefined) { + if (block.disposition === 'never' || block.disposition === 'ref') t.disposition = block.disposition; + else out.rejected.push('disposition'); + } + return { kind: 'reference', testimony: t }; +} + +/** RFC-005 vector 2: an inline-data block claiming bulk disposition (or + * smuggling a uri beside its data) is an emitter contradiction — the host + * fails closed by withholding the inline data from context. Every lane that + * handles inline media MUST consult this before its data branch. */ +export function isInlineContradiction(block: unknown): boolean { + const c = classifyBlock(block); + return c.kind === 'inline' && c.contradiction; +} + +export const INLINE_WITHHELD_TEXT = + '[inline content withheld: nonconformant bulk disposition on inline data]'; + +export function isReferenceExpired(t: Pick, nowMs = Date.now()): boolean { + if (t.expiresAt === undefined) return false; + const exp = Date.parse(t.expiresAt); + return Number.isNaN(exp) ? true : exp <= nowMs; +} + +/** Strip C0/C1 controls plus bidi marks/overrides/isolates; bound length. + * Labels are labels — storage naming is host-generated elsewhere (§7.3). */ +export function sanitizeLabel(s: string, maxChars: number): string { + const cleaned = s.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, ''); + if (cleaned.length <= maxChars) return cleaned; + let cut = cleaned.slice(0, Math.max(0, maxChars - 1)); + // never split a surrogate pair at the truncation boundary + const last = cut.charCodeAt(cut.length - 1); + if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1); + return cut + '…'; +} + +export function formatSizeBytes(n: number): string { + if (n < 1024) return `${n}B`; + if (n < 1024 * 1024) return `~${Math.round(n / 1024)}KB`; + if (n < 1024 * 1024 * 1024) return `~${(n / (1024 * 1024)).toFixed(1)}MB`; + return `~${(n / (1024 * 1024 * 1024)).toFixed(1)}GB`; +} + +// ============================================================================ +// Host-private reference record (§6.2) + reference ids (§5) +// ============================================================================ + +export interface ReferenceRecord { + refId: string; + serverId?: string; + testimony: ReferenceTestimony; + receivedAt: number; +} + +const MAX_RECORDS = 2000; + +/** + * Host-private store of received references. Raw URIs live here and in stubs + * never (under `never`) or by policy only (`ref`/absent). Ids are stable and + * never reused within the process (vector 20); eviction is FIFO past + * MAX_RECORDS and a stale lookup returns undefined — a defined miss, never a + * different record. + * + * Process-wide singleton by design: ids are host-local *names*, not + * capabilities, and the record itself carries serverId for any future + * origin-bound fetcher. + */ +export class ReferenceRegistry { + private records = new Map(); + /** (serverId|uri) → refId: repeated references to the same payload share a + * record, so the id a model sees is stable across serializations (each + * tool result is serialized twice: native wire + history) and across + * repeated mentions — RFC §5's "stable for the life of the session". */ + private byKey = new Map(); + private counter = 0; + + private key(uri: string, serverId?: string): string { + return `${serverId ?? '?'}|${uri}`; + } + + register(testimony: ReferenceTestimony, serverId?: string): ReferenceRecord { + const k = this.key(testimony.uri, serverId); + const existingId = this.byKey.get(k); + if (existingId !== undefined) { + const existing = this.records.get(existingId); + if (existing) return existing; + } + const refId = `ref_${(++this.counter).toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + const record: ReferenceRecord = { refId, serverId, testimony, receivedAt: Date.now() }; + this.records.set(refId, record); + this.byKey.set(k, refId); + if (this.records.size > MAX_RECORDS) { + const oldest = this.records.keys().next().value; + if (oldest !== undefined) { + const old = this.records.get(oldest); + this.records.delete(oldest); + if (old) this.byKey.delete(this.key(old.testimony.uri, old.serverId)); + } + } + return record; + } + + get(refId: string): ReferenceRecord | undefined { + return this.records.get(refId); + } +} + +export const referenceRegistry = new ReferenceRegistry(); + +// ============================================================================ +// Stub building (§5) +// ============================================================================ + +/** + * Build the model-visible stub for a reference. Output length is bounded by + * host constants and independent of every server-supplied field length. + * Under `disposition:"never"` the uri is omitted unconditionally; under + * `ref`/absent it is omitted too — this host's default policy is the opaque + * id everywhere (the safe default of §5), with retrieval via the record. + */ +export function buildReferenceStub(t: ReferenceTestimony, provenance?: string, serverId?: string): string { + const record = referenceRegistry.register(t, serverId); + const parts: string[] = []; + if (t.name) parts.push(sanitizeLabel(t.name, STUB_FIELD_CHARS)); + const meta: string[] = []; + if (t.mimeType) meta.push(sanitizeLabel(t.mimeType, STUB_FIELD_CHARS)); + if (t.sizeBytes !== undefined) meta.push(`${formatSizeBytes(t.sizeBytes)} claimed`); + if (meta.length) parts.push(meta.join(', ')); + if (provenance) parts.push(sanitizeLabel(provenance, STUB_FIELD_CHARS)); + return `[${record.refId}] ${parts.join(' — ') || 'referenced content'}`; +} + +/** + * RFC-005 treatment for one MCPL wire block, for the model-visible text + * lanes. Returns a replacement string, or null when the block should keep + * its existing (inline/text) handling. + */ +export function referenceStubOrNull(block: unknown, provenance?: string, serverId?: string): string | null { + const c = classifyBlock(block); + switch (c.kind) { + case 'text': return null; + case 'inline': + return c.contradiction ? INLINE_WITHHELD_TEXT : null; + case 'reference': return buildReferenceStub(c.testimony, provenance, serverId); + case 'invalid': return `[unrecognized content block: ${sanitizeLabel(c.reason, STUB_FIELD_CHARS)}]`; + } +} diff --git a/src/mcpl/types.ts b/src/mcpl/types.ts index 6970239..b07c81b 100644 --- a/src/mcpl/types.ts +++ b/src/mcpl/types.ts @@ -72,6 +72,12 @@ export interface McplImageContent { mimeType?: string; /** URI reference (present when using URI form) */ uri?: string; + /** RFC-005 reference testimony (uri form only). */ + sizeBytes?: number; + digest?: string; + expiresAt?: string; + name?: string; + disposition?: 'never' | 'ref'; } export interface McplAudioContent { @@ -82,12 +88,25 @@ export interface McplAudioContent { mimeType?: string; /** URI reference (present when using URI form) */ uri?: string; + /** RFC-005 reference testimony (uri form only). */ + sizeBytes?: number; + digest?: string; + expiresAt?: string; + name?: string; + disposition?: 'never' | 'ref'; } export interface McplResourceContent { type: 'resource'; /** Resource URI (e.g., "memory://facts/12345") */ uri: string; + /** RFC-005 §3 reference testimony (all optional, all server claims). */ + mimeType?: string; + sizeBytes?: number; + digest?: string; + expiresAt?: string; + name?: string; + disposition?: 'never' | 'ref'; } // ============================================================================ @@ -1233,4 +1252,10 @@ export interface McpToolResultContent { data?: string; mimeType?: string; uri?: string; + /** RFC-005 §3 reference testimony (uri form only; server claims). */ + sizeBytes?: number; + digest?: string; + expiresAt?: string; + name?: string; + disposition?: 'never' | 'ref'; } diff --git a/src/tool-result-history.ts b/src/tool-result-history.ts index 7bf8783..5faec4f 100644 --- a/src/tool-result-history.ts +++ b/src/tool-result-history.ts @@ -15,6 +15,7 @@ */ import { safeSlice } from './safe-slice.js'; +import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './mcpl/references.js'; /** * House-safe default inline cap (chars) for tool results, error results, and @@ -61,10 +62,20 @@ function tryHistoryStringFromContentArray(data: unknown): string | null { const b = raw as { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown }; if (b.type === 'text' && typeof b.text === 'string') { parts.push(b.text); + } else if (isInlineContradiction(b)) { + // RFC-005 vector 2: checked before the data branch so inline data + // claiming bulk disposition is withheld, not rendered. + parts.push(INLINE_WITHHELD_TEXT); } else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') { // Decoded byte estimate from base64 length (3/4 ratio, rounded). const approxBytes = Math.floor(b.data.length * 3 / 4); parts.push(`[image: ${b.mimeType}, ${formatSize(approxBytes)}]`); + } else if (b.type === 'resource' || ((b.type === 'image' || b.type === 'audio') && typeof (b as { uri?: unknown }).uri === 'string')) { + // RFC-005 reference block: a bounded stub, never the raw block JSON + // (which used to inline the URI — and the whole array — into history). + const stub = referenceStubOrNull(b, 'from tool result'); + if (stub === null) return null; + parts.push(stub); } else { return null; } diff --git a/test/mcpl-push-convert.test.ts b/test/mcpl-push-convert.test.ts new file mode 100644 index 0000000..9f7b592 --- /dev/null +++ b/test/mcpl-push-convert.test.ts @@ -0,0 +1,49 @@ +/** + * RFC-005 treatment on the push lane's block converter — the RFC's priority + * lane (unilateral context entry, vector 8's substrate). + * + * Run: node --import tsx --test test/mcpl-push-convert.test.ts + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { convertBlock } from '../src/mcpl/push-handler.js'; +import type { McplContentBlock } from '../src/mcpl/types.js'; + +const t = (b: unknown) => convertBlock(b as McplContentBlock) as { type: string; text?: string; source?: { type: string } }; + +test('resource block converts to a bounded stub, never the raw uri', () => { + const out = t({ type: 'resource', uri: 'https://host/files?path=x.wav', mimeType: 'audio/wav', + sizeBytes: 1000, name: 'x.wav', disposition: 'never' }); + assert.equal(out.type, 'text'); + assert.ok(/\[ref_[a-z0-9_]+\]/.test(out.text!)); + assert.ok(!out.text!.includes('https://host'), 'uri leaked into push content'); +}); + +test('vector 2: inline image data claiming disposition is withheld on the push lane', () => { + const out = t({ type: 'image', data: 'AAAA', mimeType: 'image/png', disposition: 'never' }); + assert.equal(out.type, 'text'); + assert.ok(out.text!.includes('withheld')); +}); + +test('undecorated inline image keeps native handling (absent testimony = host default)', () => { + const out = t({ type: 'image', data: 'AAAA', mimeType: 'image/png' }); + assert.equal(out.type, 'image'); +}); + +test('uri-form image with disposition stubs instead of becoming a provider url source', () => { + const out = t({ type: 'image', uri: 'https://host/img.png', disposition: 'ref' }); + assert.equal(out.type, 'text'); + assert.ok(/\[ref_/.test(out.text!)); +}); + +test('undecorated uri-form image keeps url-source handling (behavior preserved)', () => { + const out = t({ type: 'image', uri: 'https://host/img.png' }); + assert.equal(out.type, 'image'); + assert.equal(out.source?.type, 'url'); +}); + +test('unknown block type fails visibly, never undefined', () => { + const out = t({ type: 'holo', frames: 9000 }); + assert.equal(out.type, 'text'); + assert.ok(out.text!.includes('unrecognized content block')); +}); diff --git a/test/tool-result-history.test.ts b/test/tool-result-history.test.ts index f9cd065..07b2e36 100644 --- a/test/tool-result-history.test.ts +++ b/test/tool-result-history.test.ts @@ -85,3 +85,74 @@ test('null entry in array falls back to JSON', () => { test('empty array: empty string', () => { assert.equal(toolResultDataToHistoryString([]), ''); }); + +// ── RFC-005 bulk content references (vst-mcpl is the live emitter) ── + +const RFC005_REF = { + type: 'resource', + uri: 'https://mythoss-mac-mini.tail01efee.ts.net/files?path=%2Fchord.wav', + mimeType: 'audio/wav', + sizeBytes: 4233704, + digest: 'sha256:' + 'A'.repeat(43), + name: 'chord.wav', + disposition: 'never', +}; + +test('RFC-005 resource block becomes a stub, never JSON, never the URI', () => { + const data = [ + { type: 'text', text: '{"peak":0.6}' }, + RFC005_REF, + ]; + const out = toolResultDataToHistoryString(data); + assert.ok(out.includes('{"peak":0.6}')); + assert.ok(out.includes('chord.wav') && out.includes('audio/wav'), out); + assert.ok(/\[ref_[a-z0-9_]+\]/.test(out), 'stub must carry a reference id'); + assert.ok(!out.includes('mythoss-mac-mini'), 'raw URI leaked under disposition:never'); + assert.ok(!out.includes('"type":"resource"'), 'raw block JSON leaked'); +}); + +test('RFC-005: oversized metadata cannot inflate the history string (vector 17)', () => { + const data = [{ ...RFC005_REF, name: 'x'.repeat(1_000_000) }]; + const out = toolResultDataToHistoryString(data); + assert.ok(out.length < 600, `stub is ${out.length} chars`); +}); + +test('RFC-005: uri-form audio with disposition stubs like a resource', () => { + const data = [{ type: 'audio', uri: 'https://x/y.wav', sizeBytes: 10, disposition: 'ref' }]; + const out = toolResultDataToHistoryString(data); + assert.ok(/\[ref_[a-z0-9_]+\]/.test(out)); + assert.ok(!out.includes('https://x/y.wav'), 'default policy is the opaque id'); +}); + +test('RFC-005: invalid sizeBytes is dropped as a field, block still stubs (vector 15)', () => { + const data = [{ ...RFC005_REF, sizeBytes: -1 }]; + const out = toolResultDataToHistoryString(data); + assert.ok(/\[ref_[a-z0-9_]+\]/.test(out)); + assert.ok(!out.includes('claimed'), 'rejected sizeBytes must not be rendered'); + assert.ok(!out.includes('mythoss-mac-mini'), 'never survives field rejection'); +}); + +test('RFC-005 vector 2: inline data claiming bulk disposition is withheld', () => { + const fakeBase64 = 'A'.repeat(4096); + const data = [{ type: 'image', data: fakeBase64, mimeType: 'image/png', disposition: 'never' }]; + const out = toolResultDataToHistoryString(data); + assert.ok(out.includes('withheld'), out.slice(0, 120)); + assert.ok(!out.includes(fakeBase64), 'inline data leaked through bulk disposition'); + assert.ok(!out.includes('[image:'), 'must not render as a normal image'); +}); + +test('RFC-005: reference ids are stable across serializations (§5)', () => { + const data = [RFC005_REF]; + const id = (s: string) => s.match(/\[(ref_[a-z0-9_]+)\]/)?.[1]; + const first = id(toolResultDataToHistoryString(data)); + const second = id(toolResultDataToHistoryString(data)); + assert.ok(first, 'no ref id in stub'); + assert.equal(first, second, 'same reference must keep one id (wire + history serialize twice)'); +}); + +test('RFC-005: invalid disposition value on uri-form still stubs (conservative)', () => { + const data = [{ ...RFC005_REF, disposition: 'inline-ok' }]; + const out = toolResultDataToHistoryString(data); + assert.ok(/\[ref_[a-z0-9_]+\]/.test(out)); + assert.ok(!out.includes('"type":"resource"')); +});