diff --git a/src/extension/agents/claude/node/claudeCodeSessionService.ts b/src/extension/agents/claude/node/claudeCodeSessionService.ts index f230f1f9fd..9f37655aa0 100644 --- a/src/extension/agents/claude/node/claudeCodeSessionService.ts +++ b/src/extension/agents/claude/node/claudeCodeSessionService.ts @@ -23,6 +23,17 @@ type RawStoredSDKMessage = SDKMessage & { readonly timestamp: string; readonly isMeta?: boolean; } + +/** + * Minimal entry used only for parent-chain resolution. + * These entries lack a message field and are marked as meta entries + * so they're filtered from final output but still enable parent traversal. + */ +interface ChainLinkEntry { + readonly uuid: string; + readonly parentUuid: string | null; +} + interface SummaryEntry { readonly type: 'summary'; readonly summary: string; @@ -37,7 +48,7 @@ type StoredSDKMessage = SDKMessage & { } interface ParsedSessionMessage { - readonly raw: RawStoredSDKMessage; + readonly raw: RawStoredSDKMessage | ChainLinkEntry; readonly isMeta: boolean; } @@ -359,6 +370,21 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { raw: normalizedRaw, isMeta: Boolean(isMeta) }); + } else if ('uuid' in entry && entry.uuid && 'parentUuid' in entry) { + // Handle entries without 'message' field (e.g., system messages, metadata entries) + // These are needed for parent chain linking but should not appear in final output + const uuid = entry.uuid; + const parentUuid = ('parentUuid' in entry ? entry.parentUuid : null) as string | null; + + const chainLink: ChainLinkEntry = { + uuid, + parentUuid: parentUuid ?? null + }; + + rawMessages.set(uuid, { + raw: chainLink, + isMeta: true // Mark as meta so it's used for linking but filtered from output + }); } else if ('summary' in entry && entry.summary && !entry.summary.toLowerCase().startsWith('api error: 401') && !entry.summary.toLowerCase().startsWith('invalid api key')) { const summaryEntry = entry as SummaryEntry; const uuid = summaryEntry.leafUuid; @@ -453,6 +479,10 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { .trim(); } + private _isRawStoredSDKMessage(entry: RawStoredSDKMessage | ChainLinkEntry): entry is RawStoredSDKMessage { + return 'type' in entry && 'sessionId' in entry && 'timestamp' in entry; + } + private _reviveStoredMessages(rawMessages: Map): Map { const messages = new Map(); @@ -461,6 +491,11 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService { continue; } + // Non-meta entries should always be RawStoredSDKMessage, not ChainLinkEntry + if (!this._isRawStoredSDKMessage(entry.raw)) { + continue; + } + const parentUuid = this._resolveParentUuid(entry.raw.parentUuid ?? null, rawMessages); const revived = this._reviveStoredSDKMessage({ ...entry.raw, diff --git a/src/extension/agents/claude/node/test/claudeCodeSessionService.spec.ts b/src/extension/agents/claude/node/test/claudeCodeSessionService.spec.ts index 86e0bcdbb4..eadf7391fe 100644 --- a/src/extension/agents/claude/node/test/claudeCodeSessionService.spec.ts +++ b/src/extension/agents/claude/node/test/claudeCodeSessionService.spec.ts @@ -359,6 +359,87 @@ describe('ClaudeCodeSessionService', () => { }); }); + it('maintains chain through system messages without message field', async () => { + // This test verifies that system messages (which don't have a 'message' field) + // are correctly used for parent chain linking, even though they're filtered from output + const fileName = 'session-with-system-messages.jsonl'; + const timestamp = new Date().toISOString(); + + const fileContents = [ + // First user message (root) + JSON.stringify({ + parentUuid: null, + sessionId: 'test-session', + type: 'user', + message: { role: 'user', content: 'first message' }, + uuid: 'uuid-user-1', + timestamp + }), + // First assistant message + JSON.stringify({ + parentUuid: 'uuid-user-1', + sessionId: 'test-session', + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'first response' }] }, + uuid: 'uuid-assistant-1', + timestamp + }), + // System message WITHOUT 'message' field (this used to break the chain) + JSON.stringify({ + parentUuid: 'uuid-assistant-1', + sessionId: 'test-session', + type: 'system', + subtype: 'stop_hook_summary', + hookCount: 1, + uuid: 'uuid-system-1', + timestamp + }), + // Second user message (child of system message) + JSON.stringify({ + parentUuid: 'uuid-system-1', + sessionId: 'test-session', + type: 'user', + message: { role: 'user', content: 'second message' }, + uuid: 'uuid-user-2', + timestamp + }), + // Second assistant message + JSON.stringify({ + parentUuid: 'uuid-user-2', + sessionId: 'test-session', + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'second response' }] }, + uuid: 'uuid-assistant-2', + timestamp + }) + ].join('\n'); + + mockFs.mockDirectory(dirUri, [[fileName, FileType.File]]); + mockFs.mockFile(URI.joinPath(dirUri, fileName), fileContents, 1000); + + const sessions = await service.getAllSessions(CancellationToken.None); + + expect(sessions).toHaveLength(1); + const session = sessions[0]; + + // The session should have 4 messages (system message is filtered out as isMeta) + expect(session.messages).toHaveLength(4); + + // Verify the chain is intact: user1 -> assistant1 -> user2 -> assistant2 + // (system message is used for linking but not included in output) + expect(session.messages[0].uuid).toBe('uuid-user-1'); + expect(session.messages[1].uuid).toBe('uuid-assistant-1'); + expect(session.messages[2].uuid).toBe('uuid-user-2'); + expect(session.messages[3].uuid).toBe('uuid-assistant-2'); + + // Verify message content is preserved + const userMessage1 = session.messages[0] as SDKUserMessage; + expect(userMessage1.message.content).toBe('first message'); + + const userMessage2 = session.messages[2] as SDKUserMessage; + expect(userMessage2.message.content).toBe('second message'); + }); + describe('no-workspace scenario', () => { let noWorkspaceDirUri: URI; let noWorkspaceService: ClaudeCodeSessionService;