diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 2bf6ff4d..6af90ea0 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -3245,20 +3245,12 @@ export class TeamDataService { return sessionIds; } - private async extractLeadAssistantTextsFromJsonl( - jsonlPath: string, - leadName: string, - leadSessionId: string, - maxTexts: number - ): Promise { - if (maxTexts <= 0) return []; - + private async readLeadSessionJsonlTailLines(jsonlPath: string): Promise { const MAX_SCAN_BYTES = 8 * 1024 * 1024; const INITIAL_SCAN_BYTES = 256 * 1024; const rawLinesReversed: string[] = []; const seenRawLines = new Set(); - const seenMessageIds = new Set(); const handle = await fs.promises.open(jsonlPath, 'r'); try { const stat = await handle.stat(); @@ -3289,7 +3281,17 @@ export class TeamDataService { await handle.close(); } - const rawLines = rawLinesReversed.reverse(); + return rawLinesReversed.reverse(); + } + + private async extractLeadAssistantTextsFromJsonlLines( + rawLines: readonly string[], + leadName: string, + leadSessionId: string, + maxTexts: number + ): Promise { + if (maxTexts <= 0) return []; + const seenMessageIds = new Set(); const texts: InboxMessage[] = []; let syntheticBuffer: { firstMsg: Record; @@ -3475,13 +3477,15 @@ export class TeamDataService { } const parse = async (): Promise => { + const rawLines = await this.readLeadSessionJsonlTailLines(jsonlPath); const [assistantTexts, commandResults] = await Promise.all([ - this.extractLeadAssistantTextsFromJsonl(jsonlPath, leadName, leadSessionId, maxTexts), + this.extractLeadAssistantTextsFromJsonlLines(rawLines, leadName, leadSessionId, maxTexts), extractLeadSessionMessagesFromJsonl({ jsonlPath, leadName, leadSessionId, maxMessages: maxTexts, + rawLines, }), ]); const combined = [...assistantTexts, ...commandResults]; diff --git a/src/main/services/team/leadSessionMessageExtractor.ts b/src/main/services/team/leadSessionMessageExtractor.ts index 0124f395..6d4a7df1 100644 --- a/src/main/services/team/leadSessionMessageExtractor.ts +++ b/src/main/services/team/leadSessionMessageExtractor.ts @@ -16,6 +16,7 @@ interface LeadSessionMessageExtractorOptions { leadName: string; leadSessionId: string; maxMessages: number; + rawLines?: readonly string[]; } function getMessageText(message: ParsedMessage): string { @@ -98,50 +99,61 @@ export async function extractLeadSessionMessagesFromJsonl({ leadName, leadSessionId, maxMessages, + rawLines, }: LeadSessionMessageExtractorOptions): Promise { if (maxMessages <= 0) return []; const parsedMessagesReversed: ParsedMessage[] = []; const seenScanKeys = new Set(); - const handle = await fs.promises.open(jsonlPath, 'r'); + const collectLine = (rawLine: string | undefined): void => { + const trimmed = rawLine?.trim(); + if (!trimmed) return; - try { - const stat = await handle.stat(); - const fileSize = stat.size; - - let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize); - while (scanBytes <= MAX_SCAN_BYTES) { - const start = Math.max(0, fileSize - scanBytes); - const buffer = Buffer.alloc(scanBytes); - await handle.read(buffer, 0, scanBytes, start); - const chunk = buffer.toString('utf8'); - - const lines = chunk.split(/\r?\n/); - const fromIndex = start > 0 ? 1 : 0; - - for (let i = lines.length - 1; i >= fromIndex; i--) { - const trimmed = lines[i]?.trim(); - if (!trimmed) continue; - - let parsed: ParsedMessage | null = null; - try { - parsed = parseJsonlLine(trimmed); - } catch { - parsed = null; - } - if (!parsed || parsed.isSidechain) continue; - - const scanKey = buildScanKey(parsed, trimmed); - if (seenScanKeys.has(scanKey)) continue; - seenScanKeys.add(scanKey); - parsedMessagesReversed.push(parsed); - } - - if (scanBytes === fileSize) break; - scanBytes = Math.min(fileSize, scanBytes * 2); + let parsed: ParsedMessage | null = null; + try { + parsed = parseJsonlLine(trimmed); + } catch { + parsed = null; + } + if (!parsed || parsed.isSidechain) return; + + const scanKey = buildScanKey(parsed, trimmed); + if (seenScanKeys.has(scanKey)) return; + seenScanKeys.add(scanKey); + parsedMessagesReversed.push(parsed); + }; + + if (rawLines) { + for (let i = rawLines.length - 1; i >= 0; i--) { + collectLine(rawLines[i]); + } + } else { + const handle = await fs.promises.open(jsonlPath, 'r'); + + try { + const stat = await handle.stat(); + const fileSize = stat.size; + + let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize); + while (scanBytes <= MAX_SCAN_BYTES) { + const start = Math.max(0, fileSize - scanBytes); + const buffer = Buffer.alloc(scanBytes); + await handle.read(buffer, 0, scanBytes, start); + const chunk = buffer.toString('utf8'); + + const lines = chunk.split(/\r?\n/); + const fromIndex = start > 0 ? 1 : 0; + + for (let i = lines.length - 1; i >= fromIndex; i--) { + collectLine(lines[i]); + } + + if (scanBytes === fileSize) break; + scanBytes = Math.min(fileSize, scanBytes * 2); + } + } finally { + await handle.close(); } - } finally { - await handle.close(); } const parsedMessages = parsedMessagesReversed.reverse(); diff --git a/test/main/services/team/leadSessionMessageExtractor.test.ts b/test/main/services/team/leadSessionMessageExtractor.test.ts index 016e0d49..e2f4d4b4 100644 --- a/test/main/services/team/leadSessionMessageExtractor.test.ts +++ b/test/main/services/team/leadSessionMessageExtractor.test.ts @@ -145,4 +145,38 @@ describe('extractLeadSessionMessagesFromJsonl', () => { text: 'Detached output', }); }); + + it('extracts command outputs from preloaded raw lines without reading the jsonl file', async () => { + const rawLines = [ + createUserEntry( + 'user-slash-raw', + '2026-03-27T12:00:00.000Z', + '/costcost' + ), + createUserEntry( + 'stdout-raw', + '2026-03-27T12:00:01.000Z', + 'Total cost: $1.23' + ), + ].map((entry) => JSON.stringify(entry)); + + const messages = await extractLeadSessionMessagesFromJsonl({ + jsonlPath: path.join(os.tmpdir(), 'missing-lead-session.jsonl'), + leadName: 'team-lead', + leadSessionId: 'lead-1', + maxMessages: 20, + rawLines, + }); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + from: 'team-lead', + messageKind: 'slash_command_result', + commandOutput: { + stream: 'stdout', + commandLabel: '/cost', + }, + text: 'Total cost: $1.23', + }); + }); });