Skip to content

Commit 2e58cf5

Browse files
committed
fix parser replay and multi-db handling
1 parent feb985b commit 2e58cf5

4 files changed

Lines changed: 269 additions & 174 deletions

File tree

src/__tests__/gemini-parser.test.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,9 @@ describe('gemini parser hardening', () => {
189189
'Interim answer that should be rewound away',
190190
);
191191
expect(context.filesModified).toContain('login.ts');
192-
expect(context.sessionNotes?.tokenUsage).toEqual({ input: 120, output: 30 });
193-
expect(context.sessionNotes?.cacheTokens).toEqual({ creation: 0, read: 8 });
194-
expect(context.sessionNotes?.thinkingTokens).toBe(6);
192+
expect(context.sessionNotes?.tokenUsage).toEqual({ input: 70, output: 20 });
193+
expect(context.sessionNotes?.cacheTokens).toEqual({ creation: 0, read: 5 });
194+
expect(context.sessionNotes?.thinkingTokens).toBe(4);
195195
expect(context.sessionNotes?.model).toBe('gemini-2.5-pro');
196196
});
197197

@@ -230,4 +230,35 @@ describe('gemini parser hardening', () => {
230230
expect(sessions[0].id).toBe('gemini-legacy-session');
231231
expect(sessions[0].summary).toBe('Handle the fallback path');
232232
});
233+
234+
it('skips malformed trailing JSONL lines instead of discarding the whole session', async () => {
235+
const home = makeGeminiHome();
236+
const filePath = writeGeminiJsonlSession({
237+
home,
238+
projectId: 'proj-short-id',
239+
fileName: 'session-2026-04-15T10-00-malformed1234.jsonl',
240+
projects: { '/tmp/gemini-project': 'proj-short-id' },
241+
records: [
242+
{
243+
sessionId: 'gemini-malformed-session',
244+
projectHash: 'proj-short-id',
245+
startTime: '2026-04-15T10:00:00.000Z',
246+
lastUpdated: '2026-04-15T10:00:00.000Z',
247+
},
248+
{
249+
id: 'msg-user-1',
250+
timestamp: '2026-04-15T10:00:01.000Z',
251+
type: 'user',
252+
content: [{ type: 'text', text: 'Parser should survive a bad tail line' }],
253+
},
254+
],
255+
});
256+
fs.appendFileSync(filePath, '{"broken":\n', 'utf8');
257+
258+
const { parseGeminiSessions } = await loadGeminiParser(home);
259+
const sessions = await parseGeminiSessions();
260+
261+
expect(sessions).toHaveLength(1);
262+
expect(sessions[0].id).toBe('gemini-malformed-session');
263+
});
233264
});

src/__tests__/opencode.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,38 @@ describe('OpenCode parser', () => {
346346
}
347347
});
348348

349+
it('discovers sessions from channel DBs even when the default opencode.db also exists', async () => {
350+
const defaultFixture = createOpenCodeSqliteFixture('opencode.db', {
351+
sessionId: 'ses_default',
352+
sessionTitle: 'Default DB session',
353+
});
354+
const channelFixture = createOpenCodeChannelSqliteFixture('opencode-preview.db', {
355+
sessionId: 'ses_preview',
356+
sessionTitle: 'Preview DB session',
357+
});
358+
359+
try {
360+
const mergedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-parser-merged-'));
361+
const xdgDataHome = path.join(mergedRoot, 'xdg-data');
362+
const dbDir = path.join(xdgDataHome, 'opencode');
363+
fs.mkdirSync(dbDir, { recursive: true });
364+
fs.copyFileSync(defaultFixture.dbPath, path.join(dbDir, 'opencode.db'));
365+
fs.copyFileSync(channelFixture.dbPath, path.join(dbDir, 'opencode-preview.db'));
366+
367+
process.env.XDG_DATA_HOME = xdgDataHome;
368+
process.env.OPENCODE_DB = '';
369+
370+
const { parseOpenCodeSessions } = await importOpenCodeParser();
371+
const sessions = await parseOpenCodeSessions();
372+
373+
expect(sessions.map((session) => session.id)).toContain('ses_default');
374+
expect(sessions.map((session) => session.id)).toContain('ses_preview');
375+
} finally {
376+
defaultFixture.cleanup();
377+
channelFixture.cleanup();
378+
}
379+
});
380+
349381
it('keeps high-value non-text SQLite parts in extracted recent messages', async () => {
350382
const fixture = createOpenCodeSqliteFixture('opencode.db', {
351383
sessionId: 'ses_parts',

src/parsers/gemini.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ async function parseJsonlSessionFile(filePath: string): Promise<GeminiSessionDat
126126
const rl = readline.createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
127127
const sessionState: Partial<GeminiSessionData> = {};
128128
const messages: GeminiMessage[] = [];
129+
const messageIndexById = new Map<string, number>();
129130

130131
try {
131132
for await (const rawLine of rl) {
@@ -136,8 +137,8 @@ async function parseJsonlSessionFile(filePath: string): Promise<GeminiSessionDat
136137
try {
137138
record = JSON.parse(line) as GeminiJsonlRecord;
138139
} catch (err) {
139-
logger.debug('gemini: invalid JSONL record', filePath, err);
140-
return null;
140+
logger.debug('gemini: skipping malformed JSONL record', filePath, err);
141+
continue;
141142
}
142143

143144
if (record.$set && typeof record.$set === 'object') {
@@ -149,13 +150,28 @@ async function parseJsonlSessionFile(filePath: string): Promise<GeminiSessionDat
149150
const rewindIndex = findRewindIndex(messages, record.$rewindTo);
150151
if (rewindIndex >= 0) {
151152
messages.length = rewindIndex;
153+
for (const [messageId, index] of messageIndexById.entries()) {
154+
if (index >= rewindIndex) {
155+
messageIndexById.delete(messageId);
156+
}
157+
}
152158
}
153159
continue;
154160
}
155161

156162
const message = toGeminiMessage(record);
157163
if (message) {
158-
messages.push(message);
164+
if (message.id) {
165+
const existingIndex = messageIndexById.get(message.id);
166+
if (existingIndex !== undefined) {
167+
messages[existingIndex] = message;
168+
} else {
169+
messageIndexById.set(message.id, messages.length);
170+
messages.push(message);
171+
}
172+
} else {
173+
messages.push(message);
174+
}
159175
continue;
160176
}
161177

0 commit comments

Comments
 (0)