mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-01 02:40:47 +02:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0e29d7d1a3
commit
a104471272
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user