mirror of https://github.com/garrytan/gstack.git
207 lines
7.2 KiB
TypeScript
207 lines
7.2 KiB
TypeScript
/**
|
|
* gstack-codex-session-import — backfill question-log from Codex JSONL.
|
|
*
|
|
* Plan-tune cathedral T9. Verifies the structured-file parser (D5) handles
|
|
* the two-tier recovery strategy from docs/spikes/codex-session-format.md:
|
|
* - Marker-first: <gstack-qid:foo-bar> → source=codex-import-marker.
|
|
* - Pattern fallback: D-numbered brief → source=codex-import-pattern,
|
|
* hash-only question_id.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { spawnSync } from 'child_process';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin', 'gstack-codex-session-import');
|
|
|
|
let stateRoot: string;
|
|
let fixtureCwd: string;
|
|
let cwdSlug: string;
|
|
|
|
beforeEach(() => {
|
|
stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cdximp-'));
|
|
cwdSlug = 'codex-fixture-slug';
|
|
fixtureCwd = path.join(stateRoot, cwdSlug);
|
|
fs.mkdirSync(fixtureCwd, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(stateRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeSessionFile(events: Array<Record<string, unknown>>, sessionId = 'sess-fixture'): string {
|
|
const p = path.join(stateRoot, 'rollout-fixture.jsonl');
|
|
const meta = {
|
|
timestamp: new Date().toISOString(),
|
|
type: 'session_meta',
|
|
payload: { id: sessionId, cwd: fixtureCwd },
|
|
};
|
|
const lines = [JSON.stringify(meta), ...events.map((e) => JSON.stringify(e))];
|
|
fs.writeFileSync(p, lines.join('\n') + '\n');
|
|
return p;
|
|
}
|
|
|
|
function agentMessage(text: string): Record<string, unknown> {
|
|
return {
|
|
timestamp: new Date().toISOString(),
|
|
type: 'event_msg',
|
|
payload: { type: 'agent_message', message: text },
|
|
};
|
|
}
|
|
|
|
function userMessage(text: string): Record<string, unknown> {
|
|
return {
|
|
timestamp: new Date().toISOString(),
|
|
type: 'event_msg',
|
|
payload: { type: 'user_message', message: text },
|
|
};
|
|
}
|
|
|
|
function runImport(sessionPath: string): { stdout: string; stderr: string; status: number } {
|
|
const env: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) env[k] = v;
|
|
}
|
|
env.GSTACK_STATE_ROOT = stateRoot;
|
|
env.GSTACK_QUESTION_LOG_NO_DERIVE = '1';
|
|
delete env.GSTACK_HOME;
|
|
const res = spawnSync(BIN, [sessionPath], { env, encoding: 'utf-8', cwd: ROOT });
|
|
return {
|
|
stdout: res.stdout ?? '',
|
|
stderr: res.stderr ?? '',
|
|
status: res.status ?? -1,
|
|
};
|
|
}
|
|
|
|
function readImportedEvents(): Array<Record<string, unknown>> {
|
|
const f = path.join(stateRoot, 'projects', cwdSlug, 'question-log.jsonl');
|
|
if (!fs.existsSync(f)) return [];
|
|
return fs
|
|
.readFileSync(f, 'utf-8')
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((l) => JSON.parse(l));
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Marker-first path
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('marker-first import (source=codex-import-marker)', () => {
|
|
test('extracts marker id from agent_message and pairs with next user_message', () => {
|
|
const sessionPath = writeSessionFile([
|
|
agentMessage(
|
|
'D1 — Test\nELI10: blah\n<gstack-qid:ship-test-failure-triage> Tests failed.\nRecommendation: A\nA) Fix now (recommended)\nB) Investigate\nC) Ack and ship',
|
|
),
|
|
userMessage('A'),
|
|
]);
|
|
const r = runImport(sessionPath);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('IMPORTED: 1');
|
|
const events = readImportedEvents();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].source).toBe('codex-import-marker');
|
|
expect(events[0].question_id).toBe('ship-test-failure-triage');
|
|
expect(events[0].user_choice).toContain('Fix now');
|
|
expect(events[0].recommended).toContain('Fix now');
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Pattern fallback
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('pattern fallback (source=codex-import-pattern)', () => {
|
|
test('D-numbered brief without marker → hash id + source=codex-import-pattern', () => {
|
|
const sessionPath = writeSessionFile([
|
|
agentMessage('D2 — Unmarked brief\nA) Foo (recommended)\nB) Bar'),
|
|
userMessage('A'),
|
|
]);
|
|
const r = runImport(sessionPath);
|
|
expect(r.status).toBe(0);
|
|
const events = readImportedEvents();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].source).toBe('codex-import-pattern');
|
|
expect((events[0].question_id as string).startsWith('hook-')).toBe(true);
|
|
expect(events[0].user_choice).toContain('Foo');
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Edge cases
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('edge cases', () => {
|
|
test('no AUQ-shaped events → 0 imported, exit 0', () => {
|
|
const sessionPath = writeSessionFile([
|
|
agentMessage('Just doing some work, nothing to ask.'),
|
|
]);
|
|
const r = runImport(sessionPath);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('IMPORTED: 0');
|
|
});
|
|
|
|
test('agent_message with marker but no following user_message → skipped', () => {
|
|
const sessionPath = writeSessionFile([
|
|
agentMessage('<gstack-qid:test-q> D1 — Q\nA) Foo\nB) Bar'),
|
|
// no user_message
|
|
]);
|
|
const r = runImport(sessionPath);
|
|
expect(r.status).toBe(0);
|
|
expect(readImportedEvents().length).toBe(0);
|
|
});
|
|
|
|
test('two D-briefs in sequence → both imported', () => {
|
|
const sessionPath = writeSessionFile([
|
|
agentMessage('D1 — First <gstack-qid:q1>\nA) Foo (recommended)\nB) Bar'),
|
|
userMessage('A'),
|
|
agentMessage('D2 — Second <gstack-qid:q2>\nA) Baz (recommended)\nB) Qux'),
|
|
userMessage('B'),
|
|
]);
|
|
const r = runImport(sessionPath);
|
|
expect(r.status).toBe(0);
|
|
const events = readImportedEvents();
|
|
expect(events.length).toBe(2);
|
|
expect(events[0].question_id).toBe('q1');
|
|
expect(events[1].question_id).toBe('q2');
|
|
});
|
|
|
|
test('numeric user response also resolves to letter index', () => {
|
|
const sessionPath = writeSessionFile([
|
|
agentMessage('D1 — Test <gstack-qid:numeric-q>\nA) Foo\nB) Bar\nC) Baz'),
|
|
userMessage('B - I think B is right'),
|
|
]);
|
|
runImport(sessionPath);
|
|
const events = readImportedEvents();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].user_choice).toContain('Bar');
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Default-mode (latest session) behavior
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('default mode (no args → latest)', () => {
|
|
test('returns NO_SESSIONS when sessions dir is empty', () => {
|
|
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-empty-cdx-'));
|
|
try {
|
|
const env: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) env[k] = v;
|
|
}
|
|
env.GSTACK_STATE_ROOT = stateRoot;
|
|
env.CODEX_SESSIONS_ROOT = emptyDir;
|
|
const res = spawnSync(BIN, [], { env, encoding: 'utf-8', cwd: ROOT });
|
|
expect(res.status).toBe(0);
|
|
expect(res.stdout).toMatch(/NO_SESSIONS/);
|
|
} finally {
|
|
fs.rmSync(emptyDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|