Files
boocode/apps/server/src/services/__tests__/truncate.test.ts
indifferentketchup f8fc5db929 v1.13.5: opencode truncate.ts port — full tool output retrievable via opaque id
- 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>
2026-05-22 07:55:55 +00:00

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();
});
});