mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
6a6b2b0766
* feat: add Gemini CLI session runner + JSONL parser Subprocess wrapper for `gemini -p --output-format stream-json --yolo` that spawns the Gemini CLI and parses NDJSON events (init, message, tool_use, tool_result, result) into a structured GeminiResult. Includes 10 unit tests for parseGeminiJSONL covering happy path, malformed input, empty input, missing fields, and multi-tool scenarios. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add Gemini CLI E2E tests Two E2E tests (gemini-discover-skill, gemini-review-findings) that verify gstack skills work when invoked by the Gemini CLI. Follows the same pattern as codex-e2e.test.ts — gated by EVALS=1 + binary availability, diff-based selection via touchfiles, eval persistence. - Add test/gemini-e2e.test.ts - Add Gemini entries to E2E_TOUCHFILES and GLOBAL_TOUCHFILES - Add test:gemini and test:gemini:all scripts to package.json - Add gemini-e2e.test.ts to test:evals, test:e2e, and ignore list Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.9.2.0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
105 lines
4.4 KiB
TypeScript
105 lines
4.4 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { parseGeminiJSONL } from './gemini-session-runner';
|
|
|
|
// Fixture: actual Gemini CLI stream-json output with tool use
|
|
const FIXTURE_LINES = [
|
|
'{"type":"init","timestamp":"2026-03-20T15:14:46.455Z","session_id":"test-session-123","model":"auto-gemini-3"}',
|
|
'{"type":"message","timestamp":"2026-03-20T15:14:46.456Z","role":"user","content":"list the files"}',
|
|
'{"type":"message","timestamp":"2026-03-20T15:14:49.650Z","role":"assistant","content":"I will list the files.","delta":true}',
|
|
'{"type":"tool_use","timestamp":"2026-03-20T15:14:49.690Z","tool_name":"run_shell_command","tool_id":"cmd_1","parameters":{"command":"ls"}}',
|
|
'{"type":"tool_result","timestamp":"2026-03-20T15:14:49.931Z","tool_id":"cmd_1","status":"success","output":"file1.ts\\nfile2.ts"}',
|
|
'{"type":"message","timestamp":"2026-03-20T15:14:51.945Z","role":"assistant","content":"Here are the files.","delta":true}',
|
|
'{"type":"result","timestamp":"2026-03-20T15:14:52.030Z","status":"success","stats":{"total_tokens":27147,"input_tokens":26928,"output_tokens":87,"cached":0,"duration_ms":5575,"tool_calls":1}}',
|
|
];
|
|
|
|
describe('parseGeminiJSONL', () => {
|
|
test('extracts session ID from init event', () => {
|
|
const parsed = parseGeminiJSONL(FIXTURE_LINES);
|
|
expect(parsed.sessionId).toBe('test-session-123');
|
|
});
|
|
|
|
test('concatenates assistant message deltas into output', () => {
|
|
const parsed = parseGeminiJSONL(FIXTURE_LINES);
|
|
expect(parsed.output).toBe('I will list the files.Here are the files.');
|
|
});
|
|
|
|
test('ignores user messages', () => {
|
|
const lines = [
|
|
'{"type":"message","role":"user","content":"this should be ignored"}',
|
|
'{"type":"message","role":"assistant","content":"this should be kept","delta":true}',
|
|
];
|
|
const parsed = parseGeminiJSONL(lines);
|
|
expect(parsed.output).toBe('this should be kept');
|
|
});
|
|
|
|
test('extracts tool names from tool_use events', () => {
|
|
const parsed = parseGeminiJSONL(FIXTURE_LINES);
|
|
expect(parsed.toolCalls).toHaveLength(1);
|
|
expect(parsed.toolCalls[0]).toBe('run_shell_command');
|
|
});
|
|
|
|
test('extracts total tokens from result stats', () => {
|
|
const parsed = parseGeminiJSONL(FIXTURE_LINES);
|
|
expect(parsed.tokens).toBe(27147);
|
|
});
|
|
|
|
test('skips malformed lines without throwing', () => {
|
|
const lines = [
|
|
'{"type":"init","session_id":"ok"}',
|
|
'this is not json',
|
|
'{"type":"message","role":"assistant","content":"hello","delta":true}',
|
|
'{incomplete json',
|
|
'{"type":"result","status":"success","stats":{"total_tokens":100}}',
|
|
];
|
|
const parsed = parseGeminiJSONL(lines);
|
|
expect(parsed.sessionId).toBe('ok');
|
|
expect(parsed.output).toBe('hello');
|
|
expect(parsed.tokens).toBe(100);
|
|
});
|
|
|
|
test('skips empty and whitespace-only lines', () => {
|
|
const lines = [
|
|
'',
|
|
' ',
|
|
'{"type":"init","session_id":"s1"}',
|
|
'\t',
|
|
'{"type":"result","status":"success","stats":{"total_tokens":50}}',
|
|
];
|
|
const parsed = parseGeminiJSONL(lines);
|
|
expect(parsed.sessionId).toBe('s1');
|
|
expect(parsed.tokens).toBe(50);
|
|
});
|
|
|
|
test('handles empty input', () => {
|
|
const parsed = parseGeminiJSONL([]);
|
|
expect(parsed.output).toBe('');
|
|
expect(parsed.toolCalls).toHaveLength(0);
|
|
expect(parsed.tokens).toBe(0);
|
|
expect(parsed.sessionId).toBeNull();
|
|
});
|
|
|
|
test('handles missing fields gracefully', () => {
|
|
const lines = [
|
|
'{"type":"init"}', // no session_id
|
|
'{"type":"message","role":"assistant"}', // no content
|
|
'{"type":"tool_use"}', // no tool_name
|
|
'{"type":"result","status":"success"}', // no stats
|
|
];
|
|
const parsed = parseGeminiJSONL(lines);
|
|
expect(parsed.sessionId).toBeNull();
|
|
expect(parsed.output).toBe('');
|
|
expect(parsed.toolCalls).toHaveLength(0);
|
|
expect(parsed.tokens).toBe(0);
|
|
});
|
|
|
|
test('handles multiple tool_use events', () => {
|
|
const lines = [
|
|
'{"type":"tool_use","tool_name":"run_shell_command","tool_id":"cmd_1","parameters":{"command":"ls"}}',
|
|
'{"type":"tool_use","tool_name":"read_file","tool_id":"cmd_2","parameters":{"path":"foo.ts"}}',
|
|
'{"type":"tool_use","tool_name":"run_shell_command","tool_id":"cmd_3","parameters":{"command":"cat bar.ts"}}',
|
|
];
|
|
const parsed = parseGeminiJSONL(lines);
|
|
expect(parsed.toolCalls).toEqual(['run_shell_command', 'read_file', 'run_shell_command']);
|
|
});
|
|
});
|