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
32 changes: 29 additions & 3 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ describe('Session', () => {
sendMessageStream: vi.fn(),
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getHistoryShallow: vi.fn().mockReturnValue([]),
getLastModelMessageText: vi.fn().mockReturnValue(''),
setHistory: vi.fn(),
truncateHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
Expand Down Expand Up @@ -329,6 +331,7 @@ describe('Session', () => {
{ role: 'model', parts: [{ text: 'second reply' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history);

const result = session.rewindToTurn(1);

Expand All @@ -348,6 +351,7 @@ describe('Session', () => {
{ role: 'model', parts: [{ text: 'first reply' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history);

const result = session.rewindToTurn(0);

Expand All @@ -356,9 +360,9 @@ describe('Session', () => {
});

it('rejects unreachable user turns', () => {
vi.mocked(mockChat.getHistory).mockReturnValue([
{ role: 'user', parts: [{ text: 'first' }] },
]);
const history: Content[] = [{ role: 'user', parts: [{ text: 'first' }] }];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history);

expect(() => session.rewindToTurn(2)).toThrow(
'Cannot rewind to the requested turn',
Expand Down Expand Up @@ -853,6 +857,8 @@ describe('Session', () => {
sendMessageStream: vi.fn().mockResolvedValue(createEmptyStream()),
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getHistoryShallow: vi.fn().mockReturnValue([]),
getLastModelMessageText: vi.fn().mockReturnValue(''),
} as unknown as GeminiChat;

mockChat.sendMessageStream = vi
Expand Down Expand Up @@ -1207,6 +1213,8 @@ describe('Session', () => {
sendMessageStream: vi.fn().mockResolvedValue(createEmptyStream()),
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getHistoryShallow: vi.fn().mockReturnValue([]),
getLastModelMessageText: vi.fn().mockReturnValue(''),
} as unknown as GeminiChat;
mockConfig.getSessionTokenLimit = vi.fn().mockReturnValue(100);
mockGeminiClient.tryCompressChat
Expand Down Expand Up @@ -1523,6 +1531,9 @@ describe('Session', () => {
.mockReturnValue([
{ role: 'model', parts: [{ text: 'response text' }] },
]);
mockChat.getLastModelMessageText = vi
.fn()
.mockReturnValue('response text');
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValueOnce(createEmptyStream())
Expand Down Expand Up @@ -1584,6 +1595,9 @@ describe('Session', () => {
.mockReturnValue([
{ role: 'model', parts: [{ text: 'response text' }] },
]);
mockChat.getLastModelMessageText = vi
.fn()
.mockReturnValue('response text');
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValueOnce(createEmptyStream())
Expand Down Expand Up @@ -1655,6 +1669,9 @@ describe('Session', () => {
.mockReturnValue([
{ role: 'model', parts: [{ text: 'response text' }] },
]);
mockChat.getLastModelMessageText = vi
.fn()
.mockReturnValue('response text');
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
Expand Down Expand Up @@ -2405,6 +2422,9 @@ describe('Session', () => {
.mockReturnValue([
{ role: 'model', parts: [{ text: 'response text' }] },
]);
mockChat.getLastModelMessageText = vi
.fn()
.mockReturnValue('response text');

mockChat.sendMessageStream = vi.fn().mockResolvedValue(
createStreamWithChunks([
Expand Down Expand Up @@ -2458,6 +2478,9 @@ describe('Session', () => {
.mockReturnValue([
{ role: 'model', parts: [{ text: 'response text' }] },
]);
mockChat.getLastModelMessageText = vi
.fn()
.mockReturnValue('response text');
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
Expand Down Expand Up @@ -2503,6 +2526,9 @@ describe('Session', () => {
.mockReturnValue([
{ role: 'model', parts: [{ text: 'response text' }] },
]);
mockChat.getLastModelMessageText = vi
.fn()
.mockReturnValue('response text');
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
Expand Down
14 changes: 4 additions & 10 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ export class Session implements SessionContext {
}

const chat = this.config.getGeminiClient()!.getChat();
const apiHistory = chat.getHistory();
const apiHistory = chat.getHistoryShallow();
const apiTruncateIndex = this.#computeApiTruncationIndexForUserTurn(
apiHistory,
targetTurnIndex,
Expand Down Expand Up @@ -895,16 +895,10 @@ export class Session implements SessionContext {
return { stopReason: 'end_turn' };
}

// Get response text from the chat history
const history = this.#getCurrentChat().getHistory();
const lastModelMessage = history
.filter((msg: Content) => msg.role === 'model')
.pop();
// Extract last model text without cloning the full history.
const responseText =
lastModelMessage?.parts
?.filter((p: Part): p is { text: string } & Part => 'text' in p)
.map((p: { text: string }) => p.text)
.join('') || '[no response text]';
this.#getCurrentChat().getLastModelMessageText?.() ||
Comment thread
yiliang114 marked this conversation as resolved.
'[no response text]';

const response = await messageBus.request<
HookExecutionRequest,
Expand Down
9 changes: 4 additions & 5 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2108,10 +2108,9 @@ export const AppContainer = (props: AppContainerProps) => {
const ac = new AbortController();
suggestionAbortRef.current = ac;

// Use curated history to avoid invalid/empty entries causing API errors
const fullHistory = geminiClient.getChat().getHistory(true);
const conversationHistory =
fullHistory.length > 40 ? fullHistory.slice(-40) : fullHistory;
// Only clone the tail — full structuredClone of a large resumed session
// causes transient heap peaks that trigger OOM (#4624).
const conversationHistory = geminiClient.getHistoryTail(40, true);
generatePromptSuggestion(config, conversationHistory, ac.signal, {
enableCacheSharing: settings.merged.ui?.enableCacheSharing === true,
})
Expand Down Expand Up @@ -2463,7 +2462,7 @@ export const AppContainer = (props: AppContainerProps) => {
apiTruncateIndex = computeApiTruncationIndex(
historyManager.history,
userItem.id,
geminiClient.getHistory(),
geminiClient.getHistoryShallow(),
);
if (apiTruncateIndex < 0) {
historyManager.addItem(
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/ui/commands/btwCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,11 @@ describe('btwCommand', () => {
.mockReturnValue([
{ role: 'user', parts: [{ text: '杭州天气如何?' }] },
]),
getHistoryTail: vi
.fn()
.mockReturnValue([
{ role: 'user', parts: [{ text: '杭州天气如何?' }] },
]),
getChat: vi.fn().mockReturnValue({
getGenerationConfig: vi.fn().mockReturnValue({
systemInstruction: 'You are helpful',
Expand Down Expand Up @@ -229,6 +234,10 @@ describe('btwCommand', () => {
{ role: 'user', parts: [{ text: '杭州天气如何?' }] },
{ role: 'user', parts: [{ text: '请顺便解释一下湿度怎么看' }] },
]),
getHistoryTail: vi.fn().mockReturnValue([
{ role: 'user', parts: [{ text: '杭州天气如何?' }] },
{ role: 'user', parts: [{ text: '请顺便解释一下湿度怎么看' }] },
]),
getChat: vi.fn().mockReturnValue({
getGenerationConfig: vi.fn().mockReturnValue({
systemInstruction: 'live system prompt',
Expand Down
9 changes: 2 additions & 7 deletions packages/cli/src/ui/commands/btwCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function getBtwCacheSafeParams(
geminiClient &&
typeof geminiClient === 'object' &&
typeof geminiClient.getChat === 'function' &&
typeof geminiClient.getHistory === 'function'
typeof geminiClient.getHistoryTail === 'function'
) {
const chat = geminiClient.getChat();
if (
Expand All @@ -65,12 +65,7 @@ function getBtwCacheSafeParams(
) {
const generationConfig = chat.getGenerationConfig();
if (generationConfig) {
const fullHistory = geminiClient.getHistory(true);
const maxHistoryEntries = 40;
const history =
fullHistory.length > maxHistoryEntries
? fullHistory.slice(-maxHistoryEntries)
: fullHistory;
const history = geminiClient.getHistoryTail(40, true);
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.

return {
generationConfig,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2462,7 +2462,7 @@ export const useGeminiStream = (
const toolName = toolCall.request.name;
const fileName = path.basename(filePath);
const toolCallWithSnapshotFileName = `${timestamp}-${fileName}-${toolName}.json`;
const clientHistory = await geminiClient?.getHistory();
const clientHistory = geminiClient?.getHistoryShallow();
const toolCallWithSnapshotFilePath = path.join(
checkpointDir,
toolCallWithSnapshotFileName,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/services/sessionRecap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export async function generateSessionRecap(
const geminiClient = config.getGeminiClient();
if (!geminiClient) return null;

const fullHistory = geminiClient.getChat().getHistory();
const fullHistory = geminiClient.getHistoryShallow();
if (fullHistory.length < 2) return null;

const dialog = filterToDialog(fullHistory);
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/services/sessionTitle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function makeConfig(opts: MockOptions): {
getFastModel: vi.fn(() => opts.fastModel ?? undefined),
getModel: vi.fn(() => 'qwen-plus'),
getGeminiClient: vi.fn(() => ({
getHistoryShallow: () => opts.history ?? [],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] getHistoryShallow is a plain arrow function, not a vi.fn() spy, and no test asserts it was called. Both getHistoryShallow and getChat().getHistory() return identical data. If the production code reverts to getChat().getHistory(), all 15 tests still pass silently — the core behavioral change of this PR has no regression guard.

Consider:

getHistoryShallow: vi.fn(() => opts.history ?? []),

Then assert in relevant tests:

expect(config.getGeminiClient().getHistoryShallow).toHaveBeenCalled();

Same applies to btwCommand.test.ts getHistoryTail mock.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — intentionally kept as a plain function since the tests validate behavior (correct title output) rather than call-site wiring. Adding a spy assertion here would couple the test to an implementation detail that may change if we later switch back to a different accessor.

getChat: () => ({
getHistory: () => opts.history ?? [],
}),
Expand Down Expand Up @@ -202,7 +203,10 @@ describe('tryGenerateSessionTitle', () => {
getFastModel: vi.fn(() => 'qwen-turbo'),
getModel: vi.fn(() => 'qwen-plus'),
getGeminiClient: vi.fn(() => ({
getChat: () => ({ getHistory: () => history }),
getHistoryShallow: () => history,
getChat: () => ({
getHistory: () => history,
}),
})),
getBaseLlmClient: vi.fn(() => ({ generateJson })),
} as unknown as Config;
Expand Down Expand Up @@ -240,7 +244,10 @@ describe('tryGenerateSessionTitle', () => {
getFastModel: vi.fn(() => 'qwen-turbo'),
getModel: vi.fn(() => 'qwen-plus'),
getGeminiClient: vi.fn(() => ({
getChat: () => ({ getHistory: () => history }),
getHistoryShallow: () => history,
getChat: () => ({
getHistory: () => history,
}),
})),
getBaseLlmClient: vi.fn(() => ({ generateJson })),
} as unknown as Config;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/services/sessionTitle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export async function tryGenerateSessionTitle(
const geminiClient = config.getGeminiClient();
if (!geminiClient) return { ok: false, reason: 'no_client' };

const fullHistory = geminiClient.getChat().getHistory();
const fullHistory = geminiClient.getHistoryShallow();
if (fullHistory.length < 2) return { ok: false, reason: 'empty_history' };

const dialog = filterToDialog(fullHistory);
Expand Down
Loading