Skip to content
Open
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
133 changes: 133 additions & 0 deletions packages/core/src/context/chatCompressionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,139 @@ describe('ChatCompressionService', () => {
});

describe('Reverse Token Budget Truncation', () => {
it('should preserve message and part order without mutating the original history', async () => {
vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000);
const history: Content[] = [
{ role: 'user', parts: [{ text: 'first' }, { text: 'second' }] },
{
role: 'model',
parts: [
{ text: 'before call' },
{ functionCall: { name: 'grep', args: { pattern: 'test' } } },
{ text: 'after call' },
],
},
{
role: 'user',
parts: [
{ text: 'before response' },
{
functionResponse: { name: 'grep', response: { output: 'match' } },
},
{ text: 'after response' },
],
},
{ role: 'model', parts: [] },
{ role: 'user' },
];
const originalHistory = structuredClone(history);
vi.mocked(mockChat.getHistory).mockReturnValue(history);

const result = await service.compress(
mockChat,
mockPromptId,
false,
mockModel,
mockConfig,
true,
);

expect(result.info.compressionStatus).toBe(
CompressionStatus.CONTENT_TRUNCATED,
);
expect(result.newHistory).toEqual(
history.map((content) => ({ ...content, parts: content.parts ?? [] })),
);
expect(history).toEqual(originalHistory);
});

it.each([false, true])(
'should prioritize the newest response in a multi-part message when saving fails: %s',
async (saveFails) => {
vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000);
vi.spyOn(tokenCalculation, 'estimateTokenCountSync').mockImplementation(
(parts) =>
parts.reduce(
(total, part) =>
total + (part.text?.startsWith('large-') ? 30000 : 1),
0,
),
);
const saveOutput = vi.spyOn(fileUtils, 'saveTruncatedToolOutput');
if (saveFails) {
saveOutput.mockRejectedValue(new Error('Disk full'));
} else {
saveOutput.mockResolvedValue({ outputFile: 'older-output.txt' });
}
vi.spyOn(fileUtils, 'formatTruncatedToolOutput').mockReturnValue(
'truncated older output',
);

const olderResponse: Part = {
functionResponse: {
name: 'older',
response: { output: 'large-older' },
},
};
const newerResponse: Part = {
functionResponse: {
name: 'newer',
response: { output: 'large-newer' },
},
};
const before: Part = { text: 'before' };
const between: Part = { text: 'between' };
const after: Part = { text: 'after' };
const history: Content[] = [
{
role: 'user',
parts: [before, olderResponse, between, newerResponse, after],
},
];
const originalHistory = structuredClone(history);
vi.mocked(mockChat.getHistory).mockReturnValue(history);

const result = await service.compress(
mockChat,
mockPromptId,
false,
mockModel,
mockConfig,
true,
);

expect(result.info.compressionStatus).toBe(
CompressionStatus.CONTENT_TRUNCATED,
);
expect(saveOutput).toHaveBeenCalledExactlyOnceWith(
'large-older',
'older',
1,
testTempDir,
);
expect(result.newHistory).toEqual([
{
role: 'user',
parts: [
before,
saveFails
? olderResponse
: {
functionResponse: {
name: 'older',
response: { output: 'truncated older output' },
},
},
between,
newerResponse,
after,
],
},
]);
expect(history).toEqual(originalHistory);
},
);

it('should truncate older function responses when budget is exceeded', async () => {
vi.mocked(mockConfig.getCompressionThreshold).mockResolvedValue(0.5);
vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000);
Expand Down
14 changes: 7 additions & 7 deletions packages/core/src/context/chatCompressionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ async function truncateHistoryToBudget(
config.getTruncateToolOutputThreshold(),
);

newParts.unshift({
newParts.push({
functionResponse: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...part.functionResponse,
Expand All @@ -441,26 +441,26 @@ async function truncateHistoryToBudget(
} catch (error) {
// Fallback: if truncation fails, keep the original part to avoid data loss in the chat.
debugLogger.debug('Failed to truncate history to budget:', error);
newParts.unshift(part);
newParts.push(part);
functionResponseTokenCounter += tokens;
}
} else {
// Within budget: keep the full response.
functionResponseTokenCounter += tokens;
newParts.unshift(part);
newParts.push(part);
}
} else {
// Non-tool response part: always keep.
newParts.unshift(part);
newParts.push(part);
}
}
}

// Reconstruct the message with processed (potentially truncated) parts.
truncatedHistory.unshift({ ...content, parts: newParts });
// Restore part order once, avoiding repeated front insertions.
truncatedHistory.push({ ...content, parts: newParts.reverse() });
}

return truncatedHistory;
return truncatedHistory.reverse();
}

export class ChatCompressionService {
Expand Down
Loading