Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
131 changes: 131 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,119 @@ describe('Gemini Client (client.ts)', () => {
});
});

describe('thinking block idle cleanup and latch', () => {
let mockChat: Partial<GeminiChat>;

beforeEach(() => {
const mockStream = (async function* () {
yield {
type: GeminiEventType.Content,
value: 'response',
};
})();
mockTurnRunFn.mockReturnValue(mockStream);

mockChat = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
});

it('should not strip thoughts on active session (< 1h idle)', async () => {
// Simulate a recent API completion (5 minutes ago)
client['lastApiCompletionTimestamp'] = Date.now() - 5 * 60 * 1000;
client['thinkingClearLatched'] = false;

const gen = client.sendMessageStream(
[{ text: 'Hello' }],
new AbortController().signal,
'prompt-1',
{ type: SendMessageType.UserQuery },
);
for await (const _ of gen) {
/* drain */
}

expect(
mockChat.stripThoughtsFromHistoryKeepRecent,
).not.toHaveBeenCalled();
});

it('should latch and strip thoughts after > 1h idle', async () => {
// Simulate an old API completion (2 hours ago)
client['lastApiCompletionTimestamp'] = Date.now() - 2 * 60 * 60 * 1000;
client['thinkingClearLatched'] = false;

const gen = client.sendMessageStream(
[{ text: 'Hello' }],
new AbortController().signal,
'prompt-2',
{ type: SendMessageType.UserQuery },
);
for await (const _ of gen) {
/* drain */
}

expect(client['thinkingClearLatched']).toBe(true);
expect(mockChat.stripThoughtsFromHistoryKeepRecent).toHaveBeenCalledWith(
1,
);
});

it('should keep stripping once latched even if idle < 1h', async () => {
// Pre-set latch with a recent timestamp
client['lastApiCompletionTimestamp'] = Date.now() - 5 * 60 * 1000;
client['thinkingClearLatched'] = true;

const gen = client.sendMessageStream(
[{ text: 'Hello' }],
new AbortController().signal,
'prompt-3',
{ type: SendMessageType.UserQuery },
);
for await (const _ of gen) {
/* drain */
}

expect(client['thinkingClearLatched']).toBe(true);
expect(mockChat.stripThoughtsFromHistoryKeepRecent).toHaveBeenCalledWith(
1,
);
});

it('should update lastApiCompletionTimestamp after API call', async () => {
client['lastApiCompletionTimestamp'] = null;

const before = Date.now();
const gen = client.sendMessageStream(
[{ text: 'Hello' }],
new AbortController().signal,
'prompt-4',
{ type: SendMessageType.UserQuery },
);
for await (const _ of gen) {
/* drain */
}

expect(client['lastApiCompletionTimestamp']).toBeGreaterThanOrEqual(
before,
);
});

it('should reset latch and timestamp on resetChat', async () => {
client['lastApiCompletionTimestamp'] = Date.now();
client['thinkingClearLatched'] = true;

await client.resetChat();

expect(client['thinkingClearLatched']).toBe(false);
expect(client['lastApiCompletionTimestamp']).toBeNull();
});
});

describe('tryCompressChat', () => {
const mockGetHistory = vi.fn();

Expand All @@ -436,6 +549,7 @@ describe('Gemini Client (client.ts)', () => {
addHistory: vi.fn(),
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
} as unknown as GeminiChat;
});

Expand All @@ -457,6 +571,7 @@ describe('Gemini Client (client.ts)', () => {
getHistory: vi.fn((_curated?: boolean) => chatHistory),
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockOriginalChat as GeminiChat;

Expand Down Expand Up @@ -1149,6 +1264,7 @@ describe('Gemini Client (client.ts)', () => {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
} as unknown as GeminiChat;
client['chat'] = mockChat;

Expand Down Expand Up @@ -1204,6 +1320,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1260,6 +1377,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1326,6 +1444,7 @@ hello
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1365,6 +1484,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1410,6 +1530,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1498,6 +1619,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1555,6 +1677,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -1636,6 +1759,7 @@ Other open files:
{ role: 'user', parts: [{ text: 'previous message' }] },
]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
});
Expand Down Expand Up @@ -1889,6 +2013,7 @@ Other open files:
getHistory: vi.fn().mockReturnValue([]), // Default empty history
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -2228,6 +2353,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -2265,6 +2391,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand Down Expand Up @@ -2305,6 +2432,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;

Expand All @@ -2329,6 +2457,7 @@ Other open files:
getHistory: vi.fn().mockReturnValue([]),
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
stripOrphanedUserEntriesFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
Expand Down Expand Up @@ -2361,6 +2490,7 @@ Other open files:
getHistory: vi.fn().mockReturnValue([]),
setHistory: vi.fn(),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
stripOrphanedUserEntriesFromHistory: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
Expand Down Expand Up @@ -2405,6 +2535,7 @@ Other open files:
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
stripThoughtsFromHistory: vi.fn(),
stripThoughtsFromHistoryKeepRecent: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
});
Expand Down
55 changes: 53 additions & 2 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ export interface SendMessageOptions {
};
}

/**
* Idle threshold for thinking block cleanup. After this period without any
* API call the old thinking blocks are unlikely to aid reasoning coherence
* and only waste context tokens.
*/
const THINKING_IDLE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour

export class GeminiClient {
private chat?: GeminiChat;
private sessionTurnCount = 0;
Expand All @@ -126,6 +133,25 @@ export class GeminiClient {
*/
private hasFailedCompressionAttempt = false;

/**
* Timestamp (epoch ms) of the last completed API call.
* Used to detect idle periods for thinking block cleanup.
* Starts as null — on the first query there is no prior thinking to clean,
* so the idle check is skipped until the first API call completes.
*/
private lastApiCompletionTimestamp: number | null = null;
Comment thread
wenshao marked this conversation as resolved.

/**
* Sticky-on latch for clearing thinking blocks from prior turns.
* Triggered when >1h since last API call — old thinking is no longer
* useful for reasoning coherence. Once latched, stays true to prevent
* oscillation: without it, thinking would accumulate → get stripped →
* accumulate again, causing the message prefix to change repeatedly
* (bad for any provider-side prompt caching and wastes context).
* Reset on /clear (resetChat).
*/
private thinkingClearLatched = false;

constructor(private readonly config: Config) {
this.loopDetector = new LoopDetectionService(config);
}
Expand Down Expand Up @@ -199,6 +225,9 @@ export class GeminiClient {
}

async resetChat(): Promise<void> {
// Reset thinking clear latch — fresh chat, no prior thinking to clean up
this.thinkingClearLatched = false;
this.lastApiCompletionTimestamp = null;
await this.startChat();
}

Expand Down Expand Up @@ -537,8 +566,24 @@ export class GeminiClient {
// record user message for session management
this.config.getChatRecordingService()?.recordUserMessage(request);

// strip thoughts from history before sending the message
this.stripThoughtsFromHistory();
// Thinking block cross-turn retention with idle cleanup:
// - Active session (< 1h idle): keep thinking blocks for reasoning coherence
// - Idle > 1h: clear old thinking, keep only last 1 turn to free context
// - Latch: once triggered, never revert — prevents oscillation
if (
Comment thread
wenshao marked this conversation as resolved.
!this.thinkingClearLatched &&
this.lastApiCompletionTimestamp !== null
) {
if (
Date.now() - this.lastApiCompletionTimestamp >
THINKING_IDLE_THRESHOLD_MS
) {
this.thinkingClearLatched = true;
}
}
if (this.thinkingClearLatched) {
Comment thread
wenshao marked this conversation as resolved.
this.getChat().stripThoughtsFromHistoryKeepRecent(1);
}
}
if (messageType !== SendMessageType.Retry) {
this.sessionTurnCount++;
Expand Down Expand Up @@ -680,6 +725,7 @@ export class GeminiClient {
if (arenaAgentClient) {
await arenaAgentClient.reportError('Loop detected');
}
this.lastApiCompletionTimestamp = Date.now();
return turn;
}
}
Expand All @@ -698,9 +744,14 @@ export class GeminiClient {
: 'Unknown error';
await arenaAgentClient.reportError(errorMsg);
}
this.lastApiCompletionTimestamp = Date.now();
return turn;
}
}

// Track API completion time for thinking block idle cleanup
this.lastApiCompletionTimestamp = Date.now();
Comment thread
wenshao marked this conversation as resolved.

// Fire Stop hook through MessageBus (only if hooks are enabled and registered)
// This must be done before any early returns to ensure hooks are always triggered
if (
Expand Down
Loading
Loading