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
63 changes: 63 additions & 0 deletions packages/cli/src/acp/acpResume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,4 +310,67 @@ describe('GeminiAgent Session Resume', () => {
);
});
});

it('should resolve session before initializing config or fresh chat recording', async () => {
const callOrder: string[] = [];
const resolveSessionMock = vi.fn().mockImplementation(async () => {
callOrder.push('resolveSession');
return {
sessionData: {
sessionId: 'same-minute-session-id',
messages: [{ type: 'user', content: [{ text: 'Hello' }] }],
},
sessionPath: '/path/to/session.jsonl',
};
});

(SessionSelector as unknown as Mock).mockImplementation(() => ({
resolveSession: resolveSessionMock,
}));
mockConfig.initialize.mockImplementation(async () => {
callOrder.push('config.initialize');
});
vi.mocked(mockConfig.getGeminiClient().resumeChat).mockImplementation(
async () => {
callOrder.push('geminiClient.resumeChat');
},
);
(convertSessionToClientHistory as unknown as Mock).mockReturnValue([
{ role: 'user', parts: [{ text: 'Hello' }] },
]);

await agent.loadSession({
sessionId: 'same-minute-session-id',
cwd: '/tmp',
mcpServers: [],
});

expect(callOrder).toEqual([
'resolveSession',
'config.initialize',
'geminiClient.resumeChat',
]);
expect(mockConfig.getGeminiClient().initialize).not.toHaveBeenCalled();
});

it('should not initialize config if session resolution fails', async () => {
(SessionSelector as unknown as Mock).mockImplementation(() => ({
resolveSession: vi
.fn()
.mockRejectedValue(
new Error('No previous sessions found for this project.'),
),
}));

await expect(
agent.loadSession({
sessionId: 'missing-session-id',
cwd: '/tmp',
mcpServers: [],
}),
).rejects.toThrow('No previous sessions found for this project.');

expect(mockConfig.initialize).not.toHaveBeenCalled();
expect(mockConfig.getGeminiClient().resumeChat).not.toHaveBeenCalled();
});
});
20 changes: 10 additions & 10 deletions packages/cli/src/acp/acpSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ export class AcpSessionManager {

const geminiClient = config.getGeminiClient();

const chat = await geminiClient.startChat();
const chat = geminiClient.isInitialized?.()
? geminiClient.getChat()
: await geminiClient.startChat();

const session = new Session(
sessionId,
Expand Down Expand Up @@ -165,22 +167,26 @@ export class AcpSessionManager {
{ sessionId, cwd, mcpServers }: acp.LoadSessionRequest,
authDetails: AuthDetails,
): Promise<acp.LoadSessionResponse> {
const config = await this.initializeSessionConfig(
const config = await this.prepareSessionConfig(
sessionId,
cwd,
mcpServers,
authDetails,
);

await config.storage?.initialize?.();
const sessionSelector = new SessionSelector(config.storage);

const { sessionData, sessionPath } =
await sessionSelector.resolveSession(sessionId);

await config.initialize();
startupProfiler.flush(config);
startAutoMemoryIfEnabled(config);

const clientHistory = convertSessionToClientHistory(sessionData.messages);

const geminiClient = config.getGeminiClient();
await geminiClient.initialize();
await geminiClient.resumeChat(clientHistory, {
conversation: sessionData,
filePath: sessionPath,
Expand Down Expand Up @@ -228,7 +234,7 @@ export class AcpSessionManager {
return response;
}

private async initializeSessionConfig(
private async prepareSessionConfig(
sessionId: string,
cwd: string,
mcpServers: acp.McpServer[],
Expand Down Expand Up @@ -274,12 +280,6 @@ export class AcpSessionManager {
config.setFileSystemService(acpFileSystemService);
}

// 4. Now that we are authenticated, it is safe to initialize the config
// which starts the MCP servers and other heavy resources.
await config.initialize();
startupProfiler.flush(config);
startAutoMemoryIfEnabled(config);

return config;
}

Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,20 @@ export class GeminiClient {
history: ReadonlyArray<Content | HistoryTurn>,
resumedSessionData?: ResumedSessionData,
): Promise<void> {
if (resumedSessionData?.filePath) {
const previousRecordingService = this.chat?.getChatRecordingService?.();
if (
previousRecordingService &&
previousRecordingService.getConversationFilePath?.() !==
resumedSessionData.filePath
) {
try {
await previousRecordingService.deleteCurrentSessionIfNotResumableAsync?.();
} catch {
// Best-effort cleanup of abandoned startup-only session file
}
}
}
this.chat = await this.startChat(history, resumedSessionData);
this.updateTelemetryTokenCount();
}
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/services/chatRecordingService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,49 @@ describe('ChatRecordingService', () => {
expect(files[0]).toMatch(/^session-.*-test-ses\.jsonl$/);
});

it('should not append to or poison an existing session file in the same UTC minute', async () => {
await chatRecordingService.initialize();
chatRecordingService.recordMessage({
type: 'user',
content: 'Reply with exactly: alpha',
model: 'gemini-pro',
});
chatRecordingService.recordMessage({
type: 'gemini',
content: 'alpha',
model: 'gemini-pro',
});

const originalFilePath = chatRecordingService.getConversationFilePath()!;
expect(fs.existsSync(originalFilePath)).toBe(true);

// A second fresh initialization in the same UTC minute for the same sessionId
// (e.g. during eager config initialization before resumeChat) must not append a
// context-only checkpoint onto the existing session file.
const secondRecordingService = new ChatRecordingService(mockConfig);
await secondRecordingService.initialize();
secondRecordingService.updateMessagesFromHistory([
{
id: 'ctx-1',
content: {
role: 'user',
parts: [{ text: '<session_context>env</session_context>' }],
},
} as HistoryTurn,
]);

const secondFilePath = secondRecordingService.getConversationFilePath()!;
expect(secondFilePath).not.toBe(originalFilePath);
expect(path.basename(secondFilePath)).toMatch(
/^session-.*-1-test-ses\.jsonl$/,
);

const reloadedOriginal = await loadConversationRecord(originalFilePath);
expect(reloadedOriginal).not.toBeNull();
expect(reloadedOriginal?.hasResumableContent).toBe(true);
expect(reloadedOriginal?.messages).toHaveLength(2);
});

it('should include the conversation kind when specified', async () => {
await chatRecordingService.initialize(undefined, 'subagent');
chatRecordingService.recordMessage({
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/services/chatRecordingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,10 +511,12 @@ export class ChatRecordingService {
if (this.kind === 'subagent') {
filename = `${safeSessionId}.jsonl`;
} else {
filename = `${SESSION_FILE_PREFIX}${timestamp}-${safeSessionId.slice(
0,
8,
)}.jsonl`;
const shortId = safeSessionId.slice(0, 8);
filename = `${SESSION_FILE_PREFIX}${timestamp}-${shortId}.jsonl`;
let collisionIndex = 1;
while (fs.existsSync(path.join(chatsDir, filename))) {
filename = `${SESSION_FILE_PREFIX}${timestamp}-${collisionIndex++}-${shortId}.jsonl`;
}
}
this.conversationFile = path.join(chatsDir, filename);

Expand Down
Loading