From 8b7a16f2de536be0e1be3225ad0c56b10b33f6d9 Mon Sep 17 00:00:00 2001 From: LiLBiggs4Life Date: Sat, 3 Oct 2026 22:34:32 -0500 Subject: [PATCH] Bound review evidence and retain the final report in long threads --- README.md | 16 +++++++ scripts/diff-export.mjs | 63 ++++++++++++++++++++++++ scripts/diff-export.test.mjs | 74 +++++++++++++++++++++++++++++ scripts/export-review-diff.mjs | 54 +++++++++++++++++++++ scripts/export-review-diff.test.mjs | 40 ++++++++++++++++ scripts/run-tests.mjs | 4 +- src/mcp-v2.ts | 28 ++++++++++- test/mcp-v2.test.ts | 51 ++++++++++++++++++++ 8 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 scripts/diff-export.mjs create mode 100644 scripts/diff-export.test.mjs create mode 100644 scripts/export-review-diff.mjs create mode 100644 scripts/export-review-diff.test.mjs diff --git a/README.md b/README.md index 1efd7e1..f4c3418 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,22 @@ The figures above come from the local suite run on this source revision. They de ## Build from source +For large worker histories, `get_thread({threadId, metadataOnly: true})` returns +routing and completion fields without transcript or tool history. +`get_thread_messages({threadId, limit: 1, maxChars: 4000, tail: true})` retains the +end of the latest message, including a final report after a long tool transcript. +Omit these optional fields to keep the existing full-read response. + +From a source checkout, export a bounded review patch with: + +```sh +node scripts/export-review-diff.mjs --cwd /path/to/worktree --evidence /path/to/new-evidence +``` + +The exporter selects Git paths before reading contents, skips generated evidence +and private worker state, and records every exclusion or truncation. Repeated +exports do not recursively include earlier exports or overwrite an existing result. + ```sh pnpm install --frozen-lockfile pnpm typecheck diff --git a/scripts/diff-export.mjs b/scripts/diff-export.mjs new file mode 100644 index 0000000..ec888c8 --- /dev/null +++ b/scripts/diff-export.mjs @@ -0,0 +1,63 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const inside = (root, file) => { + const relative = path.relative(root, file); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +}; + +// Evidence is reviewed from its manifest, never by recursively diffing prior +// exports. Preserve a list of every skipped path so exclusions remain visible. +export async function selectDiffExports({ cwd, evidence, changes, trackedPaths, maxFiles = 256, maxFileBytes = 2 * 1024 * 1024 }) { + const tracked = new Set(trackedPaths.map((file) => file.replaceAll('\\', '/'))); + const files = []; + const skipped = []; + const seen = new Set(); + const ordered = [...(changes.files ?? [])].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + for (const entry of ordered) { + const absolute = path.resolve(cwd, entry.path); + const relative = path.relative(cwd, absolute).replaceAll('\\', '/'); + let reason; + if (!inside(cwd, absolute)) reason = 'outside_worktree'; + else if (inside(evidence, absolute)) reason = 'current_evidence_directory'; + else if (/(^|\/)\.astra-freebuff-evidence(\/|$)/.test(relative)) reason = 'generated_evidence_directory'; + else if (/(^|\/)\.freebuff(\/|$)/.test(relative)) reason = 'private_worker_state'; + else if (/(^|\/)\.env($|\.)/.test(relative) && !relative.endsWith('.env.example')) reason = 'private_environment_file'; + else if (!tracked.has(relative) && /^docs\/agent-work\//.test(relative)) reason = 'untracked_agent_work_evidence'; + else if (/(^|\/)(diff-\d+\.json|git\.diff)$/.test(relative)) reason = 'generated_diff_artifact'; + else if (seen.has(relative)) reason = 'duplicate_path'; + else { + try { + const stat = await fs.lstat(absolute); + if (!stat.isFile()) reason = 'non_regular_file'; + else if (stat.size > maxFileBytes) reason = 'file_size_limit'; + else { + const resolved = await fs.realpath(absolute); + if (!inside(cwd, resolved)) reason = 'outside_worktree_realpath'; + else if (inside(evidence, resolved)) reason = 'evidence_realpath'; + } + } catch (error) { + // Deleted tracked files still need a diff. Other missing paths do not. + if (error.code !== 'ENOENT' || !tracked.has(relative)) reason = 'unavailable_file'; + } + } + if (!reason && files.length >= maxFiles) reason = 'file_count_limit'; + if (reason) skipped.push({ path: entry.path, reason }); + else { + seen.add(relative); + files.push({ ...entry, path: relative }); + } + } + files.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0); + return { files, skipped, limits: { maxFiles, maxFileBytes } }; +} + +export async function exportWorkerDiffs({ plan, threadId, call, save }) { + const files = []; + for (const [index, file] of plan.files.entries()) { + const artifact = `diff-${index}.json`; + await save(artifact, await call('get_diff', { threadId, file: file.path, scope: 'uncommitted' })); + files.push({ path: file.path, artifact }); + } + return { files, skipped: plan.skipped, limits: plan.limits }; +} diff --git a/scripts/diff-export.test.mjs b/scripts/diff-export.test.mjs new file mode 100644 index 0000000..52f024c --- /dev/null +++ b/scripts/diff-export.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { selectDiffExports, exportWorkerDiffs } from './diff-export.mjs'; + +test('repeated exports exclude generated evidence and retain tracked and untracked code', async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-diff-export-')); + const evidence = path.join(cwd, 'docs/agent-work/task/worker-next'); + try { + for (const file of ['src/catalog.rs', 'examples/audit.rs', 'docs/agent-work/task/worker-old/diff-0.json', 'docs/agent-work/task/labels.json']) { + await fs.mkdir(path.dirname(path.join(cwd, file)), { recursive: true }); + await fs.writeFile(path.join(cwd, file), 'preserve'); + } + await fs.mkdir(evidence, { recursive: true }); + const changes = { files: ['src/catalog.rs', 'examples/audit.rs', 'docs/agent-work/task/worker-old/diff-0.json', 'docs/agent-work/task/labels.json', 'docs/agent-work/task/worker-next/diff-0.json', '../outside.rs', 'src/catalog.rs'].map((p) => ({ path: p })) }; + const calls = []; + const call = async (name, args) => { calls.push(args.file); return { files: [{ path: args.file, diff: 'real patch' }] }; }; + const save = (name, value) => fs.writeFile(path.join(evidence, name), JSON.stringify(value)); + const first = await selectDiffExports({ cwd, evidence, changes, trackedPaths: ['src/catalog.rs'] }); + await exportWorkerDiffs({ plan: first, threadId: 'thread', call, save }); + const second = await selectDiffExports({ cwd, evidence, changes, trackedPaths: ['src/catalog.rs'] }); + await exportWorkerDiffs({ plan: second, threadId: 'thread', call, save }); + assert.deepEqual(first.files.map((f) => f.path), ['examples/audit.rs', 'src/catalog.rs']); + assert.deepEqual(second.files, first.files); + assert.deepEqual(calls, ['examples/audit.rs', 'src/catalog.rs', 'examples/audit.rs', 'src/catalog.rs']); + assert.equal(await fs.readFile(path.join(cwd, 'docs/agent-work/task/worker-old/diff-0.json'), 'utf8'), 'preserve'); + } finally { await fs.rm(cwd, { recursive: true, force: true }); } +}); + +test('limits, deleted tracked files and generated artifact names have explicit exclusions', async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-diff-export-')); + try { + await fs.writeFile(path.join(cwd, 'large.rs'), '12345'); + const plan = await selectDiffExports({ cwd, evidence: path.join(cwd, 'evidence'), changes: { files: ['large.rs', 'deleted.rs', 'diff-9.json', 'second-deleted.rs'].map((p) => ({ path: p })) }, trackedPaths: ['large.rs', 'deleted.rs', 'second-deleted.rs'], maxFileBytes: 4, maxFiles: 1 }); + assert.deepEqual(plan.files.map((f) => f.path), ['deleted.rs']); + assert.deepEqual(plan.skipped.map((f) => f.reason).sort(), ['file_size_limit', 'generated_diff_artifact', 'file_count_limit'].sort()); + } finally { await fs.rm(cwd, { recursive: true, force: true }); } +}); + +test('bounded selection is stable under input order and skips tracked exports and private state', async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-diff-order-')); + try { + const paths = ['z.rs', 'a.rs', 'git.diff', 'diff-0.json', '.freebuff/chat.json', '.env']; + for (const file of paths) { + await fs.mkdir(path.dirname(path.join(cwd, file)), { recursive: true }); + await fs.writeFile(path.join(cwd, file), 'fixture'); + } + const input = { cwd, evidence: path.join(cwd, 'evidence'), trackedPaths: paths, maxFiles: 1 }; + const first = await selectDiffExports({ ...input, changes: { files: paths.map((path) => ({ path })) } }); + const reversed = await selectDiffExports({ ...input, changes: { files: [...paths].reverse().map((path) => ({ path })) } }); + assert.deepEqual(first, reversed); + assert.deepEqual(first.files.map((file) => file.path), ['a.rs']); + assert.equal(first.skipped.filter((file) => file.reason === 'generated_diff_artifact').length, 2); + assert.ok(first.skipped.some((file) => file.reason === 'private_worker_state')); + assert.ok(first.skipped.some((file) => file.reason === 'private_environment_file')); + } finally { await fs.rm(cwd, { recursive: true, force: true }); } +}); + +test('a directory link cannot export a file outside the worktree', async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-diff-link-')); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-diff-outside-')); + try { + await fs.writeFile(path.join(outside, 'private.js'), 'outside fixture'); + await fs.symlink(outside, path.join(cwd, 'linked'), process.platform === 'win32' ? 'junction' : 'dir'); + const plan = await selectDiffExports({ cwd, evidence: path.join(cwd, 'evidence'), changes: { files: [{ path: 'linked/private.js' }] }, trackedPaths: ['linked/private.js'] }); + assert.deepEqual(plan.files, []); + assert.deepEqual(plan.skipped, [{ path: 'linked/private.js', reason: 'outside_worktree_realpath' }]); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + await fs.rm(outside, { recursive: true, force: true }); + } +}); diff --git a/scripts/export-review-diff.mjs b/scripts/export-review-diff.mjs new file mode 100644 index 0000000..d907436 --- /dev/null +++ b/scripts/export-review-diff.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { selectDiffExports, exportWorkerDiffs } from './diff-export.mjs'; + +// Inventory paths once, select once, and read only those diffs. Earlier exports +// never become inputs, even when the evidence directory lives in the worktree. +export async function captureReviewDiff({ cwd, evidence, save }) { + cwd = await fs.realpath(cwd); + evidence = path.resolve(evidence); + await fs.mkdir(evidence, { recursive: true }); + save ??= async (name, value) => fs.writeFile(path.join(evidence, name), JSON.stringify(value, null, 2) + '\n', { flag: 'wx' }); + const git = (...args) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }); + const names = (...args) => git(...args).split('\0').filter(Boolean); + const trackedPaths = [...new Set([...names('ls-files', '-z'), ...names('ls-tree', '-r', '--name-only', '-z', 'HEAD')])]; + const tracked = new Set(trackedPaths); + const changed = names('diff', 'HEAD', '--name-only', '-z'); + const untracked = names('ls-files', '--others', '--exclude-standard', '-z'); + const changes = { source: 'local_git_path_inventory', files: [...new Set([...changed, ...untracked])].map((path) => ({ path })) }; + const plan = await selectDiffExports({ cwd, evidence, changes, trackedPaths }); + await save('changes.json', changes); + await save('diff-selection.json', plan); + const patches = []; + const maxPatchCharacters = 256 * 1024; + const call = async (_name, { file }) => { + let patch; + try { + patch = tracked.has(file) ? git('diff', '--no-ext-diff', '--no-textconv', 'HEAD', '--', file) + : git('diff', '--no-ext-diff', '--no-textconv', '--no-index', '--', process.platform === 'win32' ? 'NUL' : '/dev/null', path.join(cwd, file)); + } catch (error) { + // A native no-index exit 1 means a difference, not an execution failure. + if (error.status !== 1 || typeof error.stdout !== 'string') throw error; + patch = error.stdout; + } + const excerpt = patch.slice(0, maxPatchCharacters); + patches.push(excerpt); + return { source: 'local_git', files: [{ path: file, diff: excerpt, + ...(patch.length > maxPatchCharacters ? { truncated: true, fullCharacters: patch.length } : {}) }] }; + }; + const result = await exportWorkerDiffs({ plan, call, save }); + result.limits.maxPatchCharacters = maxPatchCharacters; + await save('diff.json', result); + await fs.writeFile(path.join(evidence, 'git.diff'), patches.join('\n'), { flag: 'wx' }); + return result; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + if (args.length !== 4 || args[0] !== '--cwd' || args[2] !== '--evidence') throw new Error('Use --cwd WORKTREE --evidence NEW_DIRECTORY'); + const result = await captureReviewDiff({ cwd: args[1], evidence: args[3] }); + process.stdout.write(JSON.stringify({ files: result.files.length, skipped: result.skipped.length, evidence: path.resolve(args[3]) }) + '\n'); +} diff --git a/scripts/export-review-diff.test.mjs b/scripts/export-review-diff.test.mjs new file mode 100644 index 0000000..5d48e24 --- /dev/null +++ b/scripts/export-review-diff.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import test from 'node:test'; +import { captureReviewDiff } from './export-review-diff.mjs'; + +test('real Git exports retain code without reading earlier evidence or overwriting a result', async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'freebuff-export-git-')); + const git = (...args) => execFileSync('git', ['-C', cwd, ...args], { stdio: 'pipe' }); + try { + await fs.mkdir(path.join(cwd, 'src')); + await fs.writeFile(path.join(cwd, 'src/code.js'), 'export const value = 1;\n'); + await fs.writeFile(path.join(cwd, 'src/deleted.js'), 'export const retired = true;\n'); + git('init'); + git('add', 'src/code.js', 'src/deleted.js'); + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'Synthetic baseline'); + await fs.writeFile(path.join(cwd, 'src/code.js'), 'export const value = 2;\n'); + await fs.writeFile(path.join(cwd, 'new-code.js'), 'export const added = true;\n'); + git('rm', 'src/deleted.js'); + const old = path.join(cwd, 'docs/agent-work/task/old'); + await fs.mkdir(old, { recursive: true }); + await fs.writeFile(path.join(old, 'diff-0.json'), 'DO_NOT_EXPORT'.repeat(100000)); + const firstDir = path.join(cwd, 'docs/agent-work/task/first'); + const secondDir = path.join(cwd, 'docs/agent-work/task/second'); + const first = await captureReviewDiff({ cwd, evidence: firstDir }); + const second = await captureReviewDiff({ cwd, evidence: secondDir }); + assert.deepEqual(first.files.map((f) => f.path), ['new-code.js', 'src/code.js', 'src/deleted.js']); + assert.deepEqual(second.files, first.files); + const patch = await fs.readFile(path.join(secondDir, 'git.diff'), 'utf8'); + assert.match(patch, /value = 2/); + assert.match(patch, /added = true/); + assert.match(patch, /deleted file mode/); + assert.match(patch, /-export const retired = true/); + assert.doesNotMatch(patch, /DO_NOT_EXPORT/); + assert.equal((await fs.readFile(path.join(old, 'diff-0.json'), 'utf8')).length, 1300000); + await assert.rejects(captureReviewDiff({ cwd, evidence: secondDir }), { code: 'EEXIST' }); + } finally { await fs.rm(cwd, { recursive: true, force: true }); } +}); diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 2630419..91ec83a 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -13,10 +13,10 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); async function collect(dir) { const entries = await readdir(path.join(root, dir), { withFileTypes: true }).catch(() => []); - return entries.filter((e) => e.isFile() && e.name.endsWith('.test.ts')).map((e) => path.join(dir, e.name)); + return entries.filter((e) => e.isFile() && /\.test\.(ts|mjs)$/.test(e.name)).map((e) => path.join(dir, e.name)); } -const files = [...await collect('test'), ...await collect(path.join('test', 'integration'))].sort(); +const files = [...await collect('test'), ...await collect(path.join('test', 'integration')), ...await collect('scripts')].sort(); if (!files.length) { console.error('No test files found'); process.exit(1); } const result = spawnSync(process.execPath, ['--test', '--import', 'tsx', ...files], { diff --git a/src/mcp-v2.ts b/src/mcp-v2.ts index 2daba80..980dbfe 100644 --- a/src/mcp-v2.ts +++ b/src/mcp-v2.ts @@ -94,8 +94,32 @@ export function createV2ServerFromAdapter(adapter: V2Adapter): McpServer { read('list_projects', 'List discovered Freebuff projects.', {}, () => shapeError(async () => { await requireSessions(); return adapter.backend.listProjects(); })); read('list_threads', 'List Freebuff threads.', { projectId: id.optional() }, (args) => shapeError(async () => { await requireSessions(); const threads = await adapter.backend.listThreads(); return args.projectId ? (Array.isArray(threads) ? threads : []).filter((t) => (t as Record).projectId === args.projectId || (t as Record).projectPath === args.projectId) : threads; })); - read('get_thread', 'Read thread metadata.', { threadId: id }, (args) => shapeError(() => adapter.backend.getThread(assertSafeId(args.threadId)))); - read('get_thread_messages', 'Read visible thread messages.', { threadId: id }, (args) => shapeError(() => adapter.backend.getMessages(assertSafeId(args.threadId)))); + read('get_thread', 'Read thread metadata; metadataOnly omits transcript and tool history.', { threadId: id, metadataOnly: z.boolean().optional() }, (args) => shapeError(async () => { + const value = await adapter.backend.getThread(assertSafeId(args.threadId)); + if (!args.metadataOnly) return value; + const thread = asObject(value); + if (!thread) throw new Error('Unreadable thread metadata.'); + const fields = ['id', 'projectId', 'projectPath', 'title', 'status', 'harnessId', 'model', 'reasoningEffort', 'agentMode', 'executionMode', 'branch', 'worktreePath', 'turnState', 'queuePaused', 'lastPromptAt', 'lastTurnFinishedAt', 'lastTurnOutcome', 'missionOn', 'willContinue', 'pendingWorkCount', 'stopping', 'createdAt', 'updatedAt', 'compacting']; + const metadata: Record = {}; + for (const field of fields) { + const item = thread[field]; + if (item === undefined) continue; + if ((typeof item === 'string' && item.length <= 8192) || typeof item === 'boolean' || typeof item === 'number' || item === null) metadata[field] = item; + else throw new Error(`Invalid or oversized thread metadata field: ${field}`); + } + return metadata; + })); + read('get_thread_messages', 'Read visible thread messages. limit/maxChars bound excerpts; tail reads their end so a long message cannot hide its final report.', { threadId: id, limit: z.number().int().min(1).max(50).optional(), maxChars: z.number().int().min(128).max(16000).optional(), tail: z.boolean().optional() }, (args) => shapeError(async () => { + const value = await adapter.backend.getMessages(assertSafeId(args.threadId)); + if (args.limit === undefined && args.maxChars === undefined && !args.tail) return value; + if (!Array.isArray(value)) throw new Error('Unreadable thread messages.'); + const selected = value.slice(-(args.limit ?? (args.tail ? 1 : 10))); + const maxChars = args.maxChars ?? 4000; + return { totalMessages: value.length, omittedMessages: value.length - selected.length, messages: selected.map((message) => { + const serialized = JSON.stringify(message); + return { excerpt: args.tail ? serialized.slice(-maxChars) : serialized.slice(0, maxChars), truncated: serialized.length > maxChars, ...(args.tail ? { fromEnd: true } : {}) }; + }) }; + })); read('get_active_work', 'Read visible active work.', {}, () => shapeError(async () => { await requireSessions(); const threads = await adapter.backend.listThreads(); diff --git a/test/mcp-v2.test.ts b/test/mcp-v2.test.ts index 7cf4e7a..3dc57ca 100644 --- a/test/mcp-v2.test.ts +++ b/test/mcp-v2.test.ts @@ -157,3 +157,54 @@ test('freebuff_status returns structured capability data', async () => { const tools = (server as unknown as { _registeredTools: Record })._registeredTools; assert.ok(tools.freebuff_status, 'freebuff_status registered'); }); +test('MCP v2: bounded thread reads preserve routing while omitting oversized history', async () => { + const history = 'x'.repeat(11 * 1024 * 1024); + const messages = [{ role: 'assistant', text: history }, { role: 'assistant', text: 'finished' }]; + const thread = { id: 'large-thread', projectPath: '/tmp/project', model: 'flash', reasoningEffort: 'max', harnessId: 'codebuff', executionMode: 'local', turnState: 'idle', pendingWorkCount: 0, missionOn: false, willContinue: false, lastPromptAt: 100, lastTurnFinishedAt: 200, messages, items: [history], stopToken: 'private' }; + const backend = { getThread: async () => thread, getMessages: async () => messages } as unknown as CompositeBackend; + const sessions = new SessionManager(backend); + const server = createV2ServerFromAdapter({ backend, sessions, turns: new TurnManager(sessions) }); + const metadata = await toolOf(server, 'get_thread').handler({ threadId: 'large-thread', metadataOnly: true }, undefined); + assert.equal(metadata.structuredContent?.model, 'flash'); + assert.equal(metadata.structuredContent?.reasoningEffort, 'max'); + assert.equal(metadata.structuredContent?.lastTurnFinishedAt, 200); + assert.equal(metadata.structuredContent?.missionOn, false); + for (const field of ['messages', 'items', 'stopToken']) assert.equal(field in metadata.structuredContent!, false); + assert.ok(Buffer.byteLength(JSON.stringify(metadata)) < 4096); + + const recent = await toolOf(server, 'get_thread_messages').handler({ threadId: 'large-thread', limit: 1, maxChars: 128 }, undefined); + assert.equal(recent.structuredContent?.totalMessages, 2); + assert.equal(recent.structuredContent?.omittedMessages, 1); + assert.deepEqual(recent.structuredContent?.messages, [{ excerpt: JSON.stringify(messages[1]), truncated: false }]); + const bounded = await toolOf(server, 'get_thread_messages').handler({ threadId: 'large-thread', limit: 2, maxChars: 128 }, undefined); + const excerpts = bounded.structuredContent?.messages as Array<{ excerpt: string; truncated: boolean }>; + assert.equal(excerpts[0]!.excerpt.length, 128); + assert.equal(excerpts[0]!.truncated, true); + assert.ok(Buffer.byteLength(JSON.stringify(bounded)) < 4096); + const legacy = await toolOf(server, 'get_thread_messages').handler({ threadId: 'large-thread' }, undefined); + assert.deepEqual(legacy.structuredContent, messages, 'existing unbounded reads stay opt-in by omission for compatibility'); +}); + +test('MCP v2: oversized routing metadata is refused instead of truncated', async () => { + const backend = { getThread: async () => ({ id: 'thread', model: 'x'.repeat(8193) }) } as unknown as CompositeBackend; + const sessions = new SessionManager(backend); + const server = createV2ServerFromAdapter({ backend, sessions, turns: new TurnManager(sessions) }); + const result = await toolOf(server, 'get_thread').handler({ threadId: 'thread', metadataOnly: true }, undefined); + assert.equal(result.structuredContent?.ok, false); + assert.equal('model' in result.structuredContent!, false); +}); + +test('MCP v2: a bounded tail retains the final report of one long assistant message', async () => { + const messages = [{ role: 'user', text: 'old request' }, { role: 'assistant', text: 'work '.repeat(100000) + 'READY_FOR_REVIEW: report.md' }]; + const backend = { getMessages: async () => messages } as unknown as CompositeBackend; + const sessions = new SessionManager(backend); + const server = createV2ServerFromAdapter({ backend, sessions, turns: new TurnManager(sessions) }); + const response = await toolOf(server, 'get_thread_messages').handler({ threadId: 'thread', tail: true, maxChars: 128 }, undefined); + assert.equal(response.structuredContent?.omittedMessages, 1); + const [last] = response.structuredContent?.messages as Array<{ excerpt: string; truncated: boolean; fromEnd: boolean }>; + assert.equal(last!.excerpt.length, 128); + assert.match(last!.excerpt, /READY_FOR_REVIEW: report.md/); + assert.equal(last!.truncated, true); + assert.equal(last!.fromEnd, true); + assert.ok(Buffer.byteLength(JSON.stringify(response)) < 1024); +});