Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/extension/agents/claude/node/claudeCodeSessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,7 +48,7 @@ type StoredSDKMessage = SDKMessage & {
}

interface ParsedSessionMessage {
readonly raw: RawStoredSDKMessage;
readonly raw: RawStoredSDKMessage | ChainLinkEntry;
readonly isMeta: boolean;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, ParsedSessionMessage>): Map<string, StoredSDKMessage> {
const messages = new Map<string, StoredSDKMessage>();

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down