- New services/truncate.ts. Tmpfs storage at /tmp/boocode-truncations/ (BOOCODE_TRUNCATION_DIR env var overrides for tests). 12-char base32 opaque ids (~60 bits entropy, "tr_<id>"). Three exports: storeTruncation, readTruncation, truncateIfNeeded (wrap-or-passthrough helper). cleanupTruncations does TTL-pass (7 days) + orphan-reap (parts query on payload->'output'->>'outputPath') in one shot. - Wired four tools through truncateIfNeeded: view_file (raw full file), list_dir (full filtered+secret-filtered entries serialized one-per-line), web_fetch (textRaw pre-slice), codecontext_client (body.result pre-slice). Each returns the existing sliced view plus an optional outputPath field when truncation fires. - New view_truncated_output ToolDef. Resolves opaque id → on-disk content internally; model never sees the truncation dir. Same start_line / end_line slicing semantics as view_file. Registered in ALL_TOOLS (alpha sort places it after view_file automatically) and READ_ONLY_TOOL_NAMES. - cleanupTruncations piggybacks on the v1.13.3 stuck-row sweeper's 60s setInterval. No-op when truncation dir is empty. Not wired (TODO follow-up): grep and find_files. file_ops returns post-cap results to the tool execute path, so the "full content" isn't recoverable without a refactor of fileOps.grep / fileOps.findFiles to expose the uncapped result. web_search is silent-slice (no truncated flag); outside scope. Five sites of seven covered; the remaining two are the only ones needing a file_ops change. Tests: 7 new in truncate.test.ts (roundtrip, unknown id, malformed id, truncateIfNeeded false/true/over-cap/storage-failure paths). 186 total (was 179). cleanupTruncations file-system half implicitly via TTL pass; orphan-reap branch covered by the live container smoke. Smoke verified end-to-end against the live container: - view_file with start_line=1, end_line=3 on CLAUDE.md → tool_result part carried outputPath "tr_cdpn1o04k6ma" + truncated=true. - /tmp/boocode-truncations/tr_cdpn1o04k6ma exists, 15876 bytes, mode 0o600, parent dir mode 0o700. - Follow-up view_truncated_output(id, start_line=50, end_line=55) returned the actual lines 50-55 of CLAUDE.md (the 808notes/BooCode bullets). - ALL_TOOLS count=20 (was 19); alpha sort places view_truncated_output between view_file and watch_changes. Closes a v1.12 catalog row that was scoped but deferred. The v1.13 parts table made outputPath ride on the existing tool_result payload with no schema change beyond the storage helper itself. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
105 lines
4.1 KiB
TypeScript
105 lines
4.1 KiB
TypeScript
// v1.13.5: truncate.ts unit coverage. Each test isolates TRUNCATION_DIR
|
|
// under os.tmpdir() so concurrent vitest runs don't collide and the suite
|
|
// stays self-cleaning. cleanupTruncations is covered by file-system half
|
|
// only; the orphan-reap branch needs a real Postgres and is tested via the
|
|
// smoke flow rather than vitest.
|
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
|
|
// Set the env var BEFORE importing the module so its module-load constant
|
|
// reads the test directory rather than /tmp/boocode-truncations.
|
|
const testDir = path.join(os.tmpdir(), `boocode-truncate-test-${process.pid}-${Date.now()}`);
|
|
process.env.BOOCODE_TRUNCATION_DIR = testDir;
|
|
|
|
const mod = await import('../truncate.js');
|
|
const { storeTruncation, readTruncation, truncateIfNeeded, MAX_TRUNCATION_BYTES } = mod;
|
|
|
|
beforeAll(async () => {
|
|
await fs.mkdir(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Drop every file between tests so id-collision asserts and orphan-style
|
|
// counts start from zero.
|
|
const entries = await fs.readdir(testDir).catch(() => [] as string[]);
|
|
await Promise.all(entries.map((n) => fs.unlink(path.join(testDir, n)).catch(() => {})));
|
|
});
|
|
|
|
describe('storeTruncation / readTruncation roundtrip', () => {
|
|
it('writes and reads identical content', async () => {
|
|
const original = 'hello\nworld\n' + 'x'.repeat(500);
|
|
const id = await storeTruncation(original);
|
|
expect(id).toMatch(/^tr_[0-9a-v]{12}$/);
|
|
const got = await readTruncation(id);
|
|
expect(got).toBe(original);
|
|
});
|
|
|
|
it('readTruncation returns null for unknown ids', async () => {
|
|
const got = await readTruncation('tr_000000000000');
|
|
expect(got).toBeNull();
|
|
});
|
|
|
|
it('readTruncation rejects malformed ids (returns null, never escapes dir)', async () => {
|
|
// Path traversal attempt; readTruncation should not even try to open.
|
|
const got = await readTruncation('../../etc/passwd');
|
|
expect(got).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('truncateIfNeeded', () => {
|
|
it('returns sliced content with no outputPath when wasTruncated=false', async () => {
|
|
const out = await truncateIfNeeded({
|
|
fullContent: 'irrelevant',
|
|
slicedContent: 'visible',
|
|
wasTruncated: false,
|
|
});
|
|
expect(out).toEqual({ content: 'visible', truncated: false });
|
|
expect('outputPath' in out).toBe(false);
|
|
});
|
|
|
|
it('stashes full content and returns outputPath when wasTruncated=true', async () => {
|
|
const full = 'line1\nline2\nline3\nline4\n';
|
|
const sliced = 'line1\nline2\n[truncated]';
|
|
const out = await truncateIfNeeded({
|
|
fullContent: full,
|
|
slicedContent: sliced,
|
|
wasTruncated: true,
|
|
});
|
|
expect(out.content).toBe(sliced);
|
|
expect(out.truncated).toBe(true);
|
|
expect(out.outputPath).toMatch(/^tr_[0-9a-v]{12}$/);
|
|
const stashed = await readTruncation(out.outputPath!);
|
|
expect(stashed).toBe(full);
|
|
});
|
|
|
|
it('skips storage but still reports truncated when fullContent exceeds the cap', async () => {
|
|
// Build content larger than MAX_TRUNCATION_BYTES. Use a Buffer to size
|
|
// it without holding a literal that triggers the gigantic-string lint.
|
|
const oversized = Buffer.alloc(MAX_TRUNCATION_BYTES + 1, 'x').toString('utf8');
|
|
const sliced = 'preview...';
|
|
const out = await truncateIfNeeded({
|
|
fullContent: oversized,
|
|
slicedContent: sliced,
|
|
wasTruncated: true,
|
|
});
|
|
expect(out).toEqual({ content: sliced, truncated: true });
|
|
expect('outputPath' in out).toBe(false);
|
|
});
|
|
|
|
it('storage failure surfaces as truncated without outputPath', async () => {
|
|
// Force writeFile to throw. Spy at the fs module level since truncate.ts
|
|
// imports { promises as fs } and storeTruncation calls fs.writeFile.
|
|
const spy = vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(new Error('disk full'));
|
|
const out = await truncateIfNeeded({
|
|
fullContent: 'short',
|
|
slicedContent: 'sliced',
|
|
wasTruncated: true,
|
|
});
|
|
expect(out).toEqual({ content: 'sliced', truncated: true });
|
|
expect('outputPath' in out).toBe(false);
|
|
spy.mockRestore();
|
|
});
|
|
});
|