Files
gstack/test/lib-llm-summarize.test.ts
T
Garry Tan a104471272 feat: add push-transcript CLI, show sessions, interactive setup, 36 tests
- cli-sync.ts: push-transcript command, show sessions with formatSessionTable(),
  upgrade cmdSetup() to interactively create .gstack-sync.json if missing
- bin/gstack-sync: add push-transcript case and help text
- test/lib-llm-summarize.test.ts: 10 tests with mocked fetch (429 retry,
  5xx backoff, malformed response, no API key, cache)
- test/lib-transcript-sync.test.ts: 22 tests for parsing, grouping,
  session file extraction, marker management, slug resolution
- test/lib-sync-show.test.ts: 4 tests for formatSessionTable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:15:26 -05:00

169 lines
5.8 KiB
TypeScript

/**
* Tests for lib/llm-summarize.ts — mock fetch, no API calls.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
import { summarizeSession } from '../lib/llm-summarize';
// Use a temp dir for cache so tests don't pollute real cache
const tmpCacheDir = path.join(os.tmpdir(), `gstack-llm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
function makeOkResponse(text: string) {
return new Response(JSON.stringify({
content: [{ type: 'text', text }],
usage: { input_tokens: 100, output_tokens: 20 },
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
// Each test gets unique messages to avoid cache collisions
let testCounter = 0;
function uniqueMessages(base: string = 'test') {
testCounter++;
return [
{ display: `${base} prompt ${testCounter} alpha`, timestamp: 1710000000000 + testCounter },
{ display: `${base} prompt ${testCounter} beta`, timestamp: 1710000060000 + testCounter },
];
}
describe('summarizeSession', () => {
let originalFetch: typeof globalThis.fetch;
let originalApiKey: string | undefined;
beforeEach(() => {
originalFetch = globalThis.fetch;
originalApiKey = process.env.ANTHROPIC_API_KEY;
// Use temp cache dir and bypass cache for clean tests
process.env.GSTACK_STATE_DIR = tmpCacheDir;
process.env.EVAL_CACHE = '0'; // Skip cache reads
});
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalApiKey !== undefined) {
process.env.ANTHROPIC_API_KEY = originalApiKey;
} else {
delete process.env.ANTHROPIC_API_KEY;
}
delete process.env.EVAL_CACHE;
});
test('returns null when ANTHROPIC_API_KEY not set', async () => {
delete process.env.ANTHROPIC_API_KEY;
const result = await summarizeSession(uniqueMessages(), ['Edit']);
expect(result).toBeNull();
});
test('returns null for empty messages', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key';
const result = await summarizeSession([], ['Edit']);
expect(result).toBeNull();
});
test('returns summary on successful API call', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key';
globalThis.fetch = (() => Promise.resolve(makeOkResponse('Fixed login page CSS.'))) as any;
const result = await summarizeSession(uniqueMessages('success'), ['Edit', 'Bash']);
expect(result).toBe('Fixed login page CSS.');
});
test('sends correct headers to Anthropic API', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key-123';
let capturedHeaders: Record<string, string> = {};
globalThis.fetch = ((url: string, init: any) => {
for (const [k, v] of Object.entries(init.headers || {})) {
capturedHeaders[k] = v as string;
}
return Promise.resolve(makeOkResponse('Summary.'));
}) as any;
await summarizeSession(uniqueMessages('headers'), null);
expect(capturedHeaders['x-api-key']).toBe('test-key-123');
expect(capturedHeaders['anthropic-version']).toBe('2023-06-01');
expect(capturedHeaders['Content-Type']).toBe('application/json');
});
test('retries on 429 with retry-after header', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key';
let callCount = 0;
globalThis.fetch = (() => {
callCount++;
if (callCount === 1) {
return Promise.resolve(new Response('', {
status: 429,
headers: { 'retry-after': '0' },
}));
}
return Promise.resolve(makeOkResponse('Retry succeeded.'));
}) as any;
const result = await summarizeSession(uniqueMessages('retry429'), null);
expect(result).toBe('Retry succeeded.');
expect(callCount).toBe(2);
});
test('retries on 5xx with backoff', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key';
let callCount = 0;
globalThis.fetch = (() => {
callCount++;
if (callCount <= 2) {
return Promise.resolve(new Response('Server Error', { status: 500 }));
}
return Promise.resolve(makeOkResponse('Recovered.'));
}) as any;
const result = await summarizeSession(uniqueMessages('retry5xx'), ['Read']);
expect(result).toBe('Recovered.');
expect(callCount).toBe(3);
});
test('returns null on persistent 429', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key';
globalThis.fetch = (() => Promise.resolve(new Response('', {
status: 429,
headers: { 'retry-after': '0' },
}))) as any;
const result = await summarizeSession(uniqueMessages('persistent429'), null);
expect(result).toBeNull();
});
test('returns null on 401 without retry', async () => {
process.env.ANTHROPIC_API_KEY = 'bad-key';
let callCount = 0;
globalThis.fetch = (() => {
callCount++;
return Promise.resolve(new Response('Unauthorized', { status: 401 }));
}) as any;
const result = await summarizeSession(uniqueMessages('auth401'), null);
expect(result).toBeNull();
expect(callCount).toBe(1);
});
test('returns null on malformed API response', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key';
globalThis.fetch = (() => Promise.resolve(new Response(
JSON.stringify({ content: [{ type: 'image', source: {} }] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
))) as any;
const result = await summarizeSession(uniqueMessages('malformed'), null);
expect(result).toBeNull();
});
test('truncates long summaries to 500 chars', async () => {
process.env.ANTHROPIC_API_KEY = 'test-key';
const longText = 'a'.repeat(600);
globalThis.fetch = (() => Promise.resolve(makeOkResponse(longText))) as any;
const result = await summarizeSession(uniqueMessages('longtext'), null);
expect(result).not.toBeNull();
expect(result!.length).toBeLessThanOrEqual(500);
});
});