Files
Colby Mchenry 3e1547bbe1 fix(mcp): bold labels instead of ATX headings in tool results (#778) (#951)
MCP tool results used Markdown ATX headings (##/###/####) for section
headers — the status summary, each search hit, every file section in an
exploration — which Markdown-rendering clients (e.g. the Claude Code
VSCode extension) blow up to H1–H4 font size, filling the transcript with
oversized lines (worst on search/explore, where the noise scales with
result count). Swap them all for bold labels, which render at body size
while keeping the same structure. CLI/TTY output (ContextBuilder) is
unchanged — the issue notes it's fine.

The format is parse-coupled, so kept in sync:
- The explore truncation boundary and the offload chunker
  (reasoning/reasoner.ts) both key off the per-file header, now a unique
  `**`-prefixed marker emitted via a shared fileSectionHeader() helper.
- Updated the offload strip regexes and switched the opt-in report-style
  prompt off ATX headings (same client, same rendering issue).
- Updated test helpers (sectionFor, sourcedFiles, the callers
  section-boundary scan) that scanned the old markers.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:06:35 -05:00

119 lines
5.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* codegraph_node FILE READ mode: a `file` with no `symbol` reads that file like
* the Read tool — current source with `<n>\t<line>` numbering (byte-for-byte
* Read's shape), narrowable with offset/limit — plus a one-line blast-radius
* header. `symbolsOnly` returns the structural map instead. Config/data files
* are summarized by key, never dumped (#383).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
describe('codegraph_node file-view (Read replacement)', () => {
let dir: string;
let cg: CodeGraph;
let h: ToolHandler;
beforeEach(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fileview-'));
fs.mkdirSync(path.join(dir, 'src'));
fs.writeFileSync(
path.join(dir, 'src', 'a.ts'),
'export function helper(x: number) {\n return x + 1;\n}\nexport class Widget {\n build() { return helper(1); }\n}\n',
);
fs.writeFileSync(
path.join(dir, 'src', 'b.ts'),
"import { helper } from './a';\n\n// a comment between symbols\nconst SETTING = 7;\nexport function useHelper() { return helper(2) + SETTING; }\n",
);
// A config/data file (#383): its values may be secrets and must never be
// dumped verbatim by the file-view.
fs.writeFileSync(
path.join(dir, 'src', 'application.properties'),
'spring.datasource.password=SUPERSECRET123\nserver.port=8080\n',
);
// A large file: exceeds the file-view line budget, so it must be windowed
// honestly (not silently truncated).
fs.writeFileSync(
path.join(dir, 'src', 'big.ts'),
'export function big() {\n' +
Array.from({ length: 2000 }, (_, i) => ` const v${i} = ${i};`).join('\n') +
'\n return 0;\n}\n',
);
cg = CodeGraph.initSync(dir, { config: { include: ['**/*.ts', '**/*.properties'], exclude: [] } });
await cg.indexAll();
h = new ToolHandler(cg);
});
afterEach(() => {
if (cg) cg.close();
fs.rmSync(dir, { recursive: true, force: true });
});
const text = async (args: Record<string, unknown>): Promise<string> =>
(await h.execute('codegraph_node', args)).content.map((c) => c.text).join('\n');
it('reads a whole file like Read by default — `<n>\\t<line>` lines (no pad), imports + gaps included', async () => {
const out = await text({ file: 'b.ts' }); // no includeCode needed — content is the default
// Byte-for-byte Read shape: line 1 is "1<TAB>import …", NOT space-padded.
expect(out).toMatch(/^1\timport \{ helper \} from '\.\/a';$/m);
expect(out).toContain('// a comment between symbols'); // inter-symbol gap (Read has it; old reconstruction dropped it)
expect(out).toContain('const SETTING = 7'); // top-level statement
expect(out).toContain('useHelper'); // the symbol body too
expect(out).not.toContain('```'); // Read has no code fence; neither do we
});
it('leads with a one-line blast-radius header (the value-add over Read)', async () => {
const out = await text({ file: 'a.ts' });
expect(out).toMatch(/used by 1 file: src\/b\.ts/); // a.ts is imported by b.ts
expect(out).toContain('return x + 1'); // still returns the source
});
it('offset/limit narrow the window exactly like Read', async () => {
const out = await text({ file: 'big.ts', offset: 1000, limit: 3 });
// Window starts at the requested line, numbered exactly: "1000<TAB> const v998 = 998;"
expect(out).toMatch(/^1000\t {2}const v998 = 998;$/m);
expect(out).not.toMatch(/^1\t/m); // line 1 is NOT shown
expect(out).toMatch(/lines 1000[-]1002 of \d+/); // honest pagination note
});
it('an offset past EOF is reported, not a crash', async () => {
const out = await text({ file: 'a.ts', offset: 9999 });
expect(out).toMatch(/past the end/i);
});
it('paginates a large file honestly by default — "lines 1N of TOTAL", never a silent truncate', async () => {
const out = await text({ file: 'big.ts' });
expect(out).toMatch(/lines 1[-]\d+ of \d+/); // explicit window note
expect(out).not.toContain('(output truncated)'); // not the generic 15k chop
expect(out).toMatch(/^1\texport function big/m); // the head of the window is real source
});
it('does NOT dump a config/data file (yaml/properties) — #383 secret safety', async () => {
const out = await text({ file: 'application.properties' });
expect(out).not.toContain('SUPERSECRET123'); // the value never reaches the agent
expect(out.toLowerCase()).toMatch(/config|values withheld/);
});
it('symbolsOnly returns the structural map, not the source', async () => {
const out = await text({ file: 'a.ts', symbolsOnly: true });
expect(out).toContain('**Symbols');
expect(out).toContain('helper');
expect(out).toContain('Widget');
expect(out).not.toContain('return x + 1'); // bodies are NOT included in the map
});
it('still works as a normal symbol lookup (no regression)', async () => {
const out = await text({ symbol: 'helper', includeCode: true });
expect(out).toContain('helper');
expect(out).toContain('return x + 1');
});
it('a miss returns a helpful message, not a crash', async () => {
const out = await text({ file: 'does-not-exist.ts' });
expect(out).toMatch(/no indexed file matches/i);
});
});