fix(core): microcompact hook continuations (#4840)

* fix(core): microcompact hook continuations

* test(core): cover hook microcompaction edge cases

* test(core): cover hook checkpoint fallback

* chore(core): refresh hook microcompaction checks

* fix(core): keep hook microcompaction cleanup best-effort

* test(core): cover hook microcompaction checkpoint state

* test(core): cover hook microcompaction review cases

* fix(core): retain hook checkpoint after microcompaction failure

* fix(core): clarify hook microcompaction comment

* fix(core): preserve hook checkpoint on cron no-op
This commit is contained in:
qqqys 2026-06-11 05:05:14 +08:00 committed by GitHub
parent 5c54a2cf8e
commit 53349a0b2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 407 additions and 93 deletions

View file

@ -165,7 +165,10 @@ export function extensionConsentString(
output.push(
t('Installing extension "{{name}}".', { name: extensionConfig.name }),
);
if (typeof extensionConfig.description === 'string' && extensionConfig.description) {
if (
typeof extensionConfig.description === 'string' &&
extensionConfig.description
) {
output.push(stripAnsi(extensionConfig.description));
}
output.push(

View file

@ -54,7 +54,10 @@ export function extensionToOutputString(
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${inline ? '' : status} ${extension.config.name} (${extension.config.version})`;
if (typeof extension.config.description === 'string' && extension.config.description) {
if (
typeof extension.config.description === 'string' &&
extension.config.description
) {
output += `\n ${t('Description:')} ${stripAnsi(extension.config.description)}`;
}
output += `\n ${t('Path:')} ${extension.path}`;

View file

@ -2450,7 +2450,8 @@ export const useGeminiStream = (
}
for (const toolCall of restorableToolCalls) {
const filePath = (toolCall.request.args['file_path'] ?? toolCall.request.args['notebook_path']) as string;
const filePath = (toolCall.request.args['file_path'] ??
toolCall.request.args['notebook_path']) as string;
if (!filePath) {
onDebugMessage(
`Skipping restorable tool call due to missing file_path: ${toolCall.request.name}`,
@ -2501,14 +2502,7 @@ export const useGeminiStream = (
}
};
saveRestorableToolCalls();
}, [
toolCalls,
config,
onDebugMessage,
history,
geminiClient,
storage,
]);
}, [toolCalls, config, onDebugMessage, history, geminiClient, storage]);
// ─── Unified notification queue (cron + background agents) ──────
const notificationQueueRef = useRef<

View file

@ -3551,8 +3551,9 @@ describe('Model Switching and Config Updates', () => {
}
it('resolves getters to the runtime view inside the frame, instance fields outside', async () => {
const { runWithRuntimeContentGenerator } =
await import('../agents/runtime/agent-context.js');
const { runWithRuntimeContentGenerator } = await import(
'../agents/runtime/agent-context.js'
);
const config = new Config(baseParams);
const parentGenerator = {
generateContentStream: vi.fn(),
@ -3599,8 +3600,9 @@ describe('Model Switching and Config Updates', () => {
});
it('falls back to the parent model id when the runtime view config has no model', async () => {
const { runWithRuntimeContentGenerator } =
await import('../agents/runtime/agent-context.js');
const { runWithRuntimeContentGenerator } = await import(
'../agents/runtime/agent-context.js'
);
const config = new Config(baseParams);
setInstanceFields(
config,

View file

@ -1588,6 +1588,14 @@ describe('Gemini Client (client.ts)', () => {
await client.resetChat();
expect(client['lastInjectedDate']).toBeUndefined();
});
it('resets Hook microcompaction checkpoint', async () => {
client['lastHookMicrocompactionTimestamp'] = Date.now();
await client.resetChat();
expect(client['lastHookMicrocompactionTimestamp']).toBeNull();
});
});
describe('history mutation invalidates FileReadCache', () => {
@ -1829,6 +1837,25 @@ describe('Gemini Client (client.ts)', () => {
expect(client['lastApiCompletionTimestamp']).toBeNull();
});
it('seeds Hook microcompaction checkpoint on user turns', async () => {
client['lastHookMicrocompactionTimestamp'] = null;
const before = Date.now();
const gen = client.sendMessageStream(
[{ text: 'Hello' }],
new AbortController().signal,
'prompt-hook-seed',
{ type: SendMessageType.UserQuery },
);
for await (const _ of gen) {
/* drain */
}
expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThanOrEqual(
before,
);
});
});
describe('microcompaction FileReadCache invalidation', () => {
@ -1924,6 +1951,246 @@ describe('Gemini Client (client.ts)', () => {
expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1);
});
it('does not abort the turn when microcompaction cleanup fails', async () => {
const { markReadEvictedFromHistory } = mockFileReadCacheStub();
markReadEvictedFromHistory.mockImplementation(() => {
throw new Error('cache disarm failed');
});
const { history } = await makeReadFileResponses(6);
client['chat'] = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue(history),
setHistory: vi.fn(),
} as unknown as GeminiChat;
client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000;
const events: ServerGeminiStreamEvent[] = [];
const stream = client.sendMessageStream(
[{ text: 'hi' }],
new AbortController().signal,
'prompt-mc-error-boundary',
{ type: SendMessageType.UserQuery },
);
for await (const event of stream) {
events.push(event);
}
expect(events).toEqual([
{ type: GeminiEventType.Content, value: 'response' },
]);
});
it('microcompacts old tool results on Hook continuations', async () => {
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
const { history } = await makeReadFileResponses(6);
const setHistory = vi.fn();
client['chat'] = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue(history),
setHistory,
} as unknown as GeminiChat;
client['lastApiCompletionTimestamp'] = Date.now();
client['lastHookMicrocompactionTimestamp'] = Date.now() - 90 * 60_000;
const stream = client.sendMessageStream(
[{ text: 'continue goal' }],
new AbortController().signal,
'prompt-mc-hook',
{ type: SendMessageType.Hook },
);
for await (const _ of stream) {
/* drain */
}
expect(setHistory).toHaveBeenCalled();
expect(clear).not.toHaveBeenCalled();
expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1);
expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThan(
Date.now() - 60_000,
);
});
it('does not abort Hook continuations when microcompaction cleanup fails', async () => {
const { markReadEvictedFromHistory } = mockFileReadCacheStub();
markReadEvictedFromHistory.mockImplementation(() => {
throw new Error('hook cache disarm failed');
});
const { history } = await makeReadFileResponses(6);
client['chat'] = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue(history),
setHistory: vi.fn(),
} as unknown as GeminiChat;
client['lastApiCompletionTimestamp'] = Date.now();
const checkpoint = Date.now() - 90 * 60_000;
client['lastHookMicrocompactionTimestamp'] = checkpoint;
mockClientDebugLogger.error.mockClear();
const events: ServerGeminiStreamEvent[] = [];
const stream = client.sendMessageStream(
[{ text: 'continue goal' }],
new AbortController().signal,
'prompt-mc-hook-error-boundary',
{ type: SendMessageType.Hook },
);
for await (const event of stream) {
events.push(event);
}
expect(events).toEqual([
{ type: GeminiEventType.Content, value: 'response' },
]);
expect(mockClientDebugLogger.error).toHaveBeenCalledWith(
expect.stringContaining(
'microcompactHistory failed: hook cache disarm failed',
),
);
expect(client['lastHookMicrocompactionTimestamp']).toBe(checkpoint);
});
it('skips the next Hook microcompaction after one just ran', async () => {
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
const { history } = await makeReadFileResponses(6);
const setHistory = vi.fn();
client['chat'] = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue(history),
setHistory,
} as unknown as GeminiChat;
client['lastApiCompletionTimestamp'] = Date.now();
client['lastHookMicrocompactionTimestamp'] = Date.now() - 90 * 60_000;
const firstStream = client.sendMessageStream(
[{ text: 'continue goal' }],
new AbortController().signal,
'prompt-mc-hook-fire',
{ type: SendMessageType.Hook },
);
for await (const _ of firstStream) {
/* drain */
}
const checkpointAfterFire = client['lastHookMicrocompactionTimestamp'];
expect(setHistory).toHaveBeenCalled();
expect(checkpointAfterFire).toBeGreaterThan(Date.now() - 60_000);
setHistory.mockClear();
clear.mockClear();
markReadEvictedFromHistory.mockClear();
const secondStream = client.sendMessageStream(
[{ text: 'continue goal again' }],
new AbortController().signal,
'prompt-mc-hook-skip',
{ type: SendMessageType.Hook },
);
for await (const _ of secondStream) {
/* drain */
}
expect(client['lastHookMicrocompactionTimestamp']).toBe(
checkpointAfterFire,
);
expect(setHistory).not.toHaveBeenCalled();
expect(clear).not.toHaveBeenCalled();
expect(markReadEvictedFromHistory).not.toHaveBeenCalled();
});
it('initializes Hook microcompaction from the last API completion timestamp', async () => {
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
const { history } = await makeReadFileResponses(6);
const setHistory = vi.fn();
client['chat'] = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue(history),
setHistory,
} as unknown as GeminiChat;
client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000;
client['lastHookMicrocompactionTimestamp'] = null;
const stream = client.sendMessageStream(
[{ text: 'continue goal' }],
new AbortController().signal,
'prompt-mc-hook-init',
{ type: SendMessageType.Hook },
);
for await (const _ of stream) {
/* drain */
}
expect(setHistory).toHaveBeenCalled();
expect(clear).not.toHaveBeenCalled();
expect(markReadEvictedFromHistory).toHaveBeenCalledTimes(1);
expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThan(
Date.now() - 60_000,
);
});
it('does not microcompact Hook continuations when the checkpoint is recent', async () => {
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
const { history } = await makeReadFileResponses(6);
const setHistory = vi.fn();
client['chat'] = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue(history),
setHistory,
} as unknown as GeminiChat;
client['lastApiCompletionTimestamp'] = Date.now() - 90 * 60_000;
client['lastHookMicrocompactionTimestamp'] = Date.now();
const stream = client.sendMessageStream(
[{ text: 'continue goal' }],
new AbortController().signal,
'prompt-mc-hook-recent',
{ type: SendMessageType.Hook },
);
for await (const _ of stream) {
/* drain */
}
expect(setHistory).not.toHaveBeenCalled();
expect(clear).not.toHaveBeenCalled();
expect(markReadEvictedFromHistory).not.toHaveBeenCalled();
});
it('seeds Hook microcompaction checkpoint to now when no API call completed', async () => {
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
const { history } = await makeReadFileResponses(6);
const setHistory = vi.fn();
client['chat'] = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue(history),
setHistory,
} as unknown as GeminiChat;
client['lastApiCompletionTimestamp'] = null;
client['lastHookMicrocompactionTimestamp'] = null;
const before = Date.now();
const stream = client.sendMessageStream(
[{ text: 'continue goal' }],
new AbortController().signal,
'prompt-mc-hook-no-api-completion',
{ type: SendMessageType.Hook },
);
for await (const _ of stream) {
/* drain */
}
expect(client['lastHookMicrocompactionTimestamp']).toBeGreaterThanOrEqual(
before,
);
expect(setHistory).not.toHaveBeenCalled();
expect(clear).not.toHaveBeenCalled();
expect(markReadEvictedFromHistory).not.toHaveBeenCalled();
});
it('falls back to a blanket clear when blanked reads cannot be linked to a path (id-less provider)', async () => {
// Provider did not populate functionCall.id, so microcompaction
// cannot recover the blanked reads' file paths. Leaving their
@ -2240,6 +2507,35 @@ describe('Gemini Client (client.ts)', () => {
expect(markReadEvictedFromHistory).toHaveBeenCalled();
});
it('does not reset the Hook checkpoint when Cron skips microcompaction', async () => {
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
const { history } = await makeReadFileResponses(6);
const setHistory = vi.fn();
client['chat'] = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue(history),
setHistory,
} as unknown as GeminiChat;
client['lastApiCompletionTimestamp'] = Date.now();
const checkpoint = Date.now() - 90 * 60_000;
client['lastHookMicrocompactionTimestamp'] = checkpoint;
const stream = client.sendMessageStream(
[{ text: 'cron job' }],
new AbortController().signal,
'prompt-cron-hook-checkpoint',
{ type: SendMessageType.Cron },
);
for await (const _ of stream) {
/* drain */
}
expect(client['lastHookMicrocompactionTimestamp']).toBe(checkpoint);
expect(setHistory).not.toHaveBeenCalled();
expect(clear).not.toHaveBeenCalled();
expect(markReadEvictedFromHistory).not.toHaveBeenCalled();
});
it('does not run microcompaction on SendMessageType.Retry', async () => {
const { clear, markReadEvictedFromHistory } = mockFileReadCacheStub();
const { history } = await makeReadFileResponses(6);

View file

@ -224,6 +224,8 @@ export class GeminiClient {
* so the idle check is skipped until the first API call completes.
*/
private lastApiCompletionTimestamp: number | null = null;
/** Cleanup checkpoint for long-running Hook continuations such as /goal. */
private lastHookMicrocompactionTimestamp: number | null = null;
constructor(private readonly config: Config) {
this.loopDetector = new LoopDetectionService(config);
@ -589,6 +591,7 @@ export class GeminiClient {
this.surfacedRelevantAutoMemoryPaths.clear();
this.cachedGitStatus = undefined;
this.lastApiCompletionTimestamp = null;
this.lastHookMicrocompactionTimestamp = null;
// startChat() rewrites the chat to its initial state. Any prior
// read_file tool results the FileReadCache still tracks are no
// longer in history, so a follow-up Read would serve a placeholder
@ -1328,6 +1331,78 @@ export class GeminiClient {
this.toolCallCount += 1;
}
private async microcompactIdleHistory(
lastCompletionTimestamp: number | null,
): Promise<boolean> {
try {
const mcResult = microcompactHistory(
this.getHistoryShallow(),
lastCompletionTimestamp,
this.config.getClearContextOnIdle(),
);
if (!mcResult.meta) {
return false;
}
const m = mcResult.meta;
this.getChat().setHistory(mcResult.history);
// Disarm only the blanked files' fast-path, keeping
// read-before-write state intact (issue #4239; rationale on
// FileReadEntry.readResidentInHistory). Any blanked read we
// can't disarm surgically forces the old blanket wipe so a
// later Read can't get a dangling file_unchanged placeholder.
const fileReadCache = this.config.getFileReadCache();
if (m.unresolvedEvictedReads > 0) {
debugLogger.debug(
`[FILE_READ_CACHE] clear after microcompaction ` +
`(${m.unresolvedEvictedReads} unresolved blanked read(s))`,
);
fileReadCache.clear();
} else {
// Concurrent stats — don't serialize N FS round-trips
// before the next turn.
const statResults = await Promise.all(
m.evictedReadPaths.map((p) =>
fsPromises.stat(p).catch(() => undefined),
),
);
// A path is surgically disarmed only if it stats AND its
// inode matches the recorded entry. A failed stat or inode
// miss could leave a stale entry armed, so fall back to the
// blanket wipe if any path is unresolvable.
let fullyDisarmed = true;
for (const stats of statResults) {
if (!stats || !fileReadCache.markReadEvictedFromHistory(stats)) {
fullyDisarmed = false;
}
}
if (fullyDisarmed) {
debugLogger.debug(
`[FILE_READ_CACHE] disarmed fast-path for ` +
`${m.evictedReadPaths.length} file(s) after microcompaction`,
);
} else {
debugLogger.debug(
'[FILE_READ_CACHE] clear after microcompaction ' +
'(an evicted path was unresolvable)',
);
fileReadCache.clear();
}
}
debugLogger.debug(
`[TIME-BASED MC] gap ${m.gapMinutes}min > ${m.thresholdMinutes}min, ` +
`cleared ${m.toolsCleared} tool result(s) + ${m.mediaCleared} media (~${m.tokensSaved} tokens), ` +
`kept ${m.toolsKept} tool / ${m.mediaKept} media`,
);
return true;
} catch (err) {
debugLogger.error(
`[TIME-BASED MC] microcompactHistory failed: ${err instanceof Error ? err.message : String(err)}`,
);
return false;
}
}
async *sendMessageStream(
request: PartListUnion,
signal: AbortSignal,
@ -1527,84 +1602,25 @@ export class GeminiClient {
}
}
// Idle cleanup: clear old tool results when idle > threshold.
// Runs on UserQuery, Cron, and Hook messages. Hook is required
// for goal-mode loops where the model drives continuation without
// user input — without this, tool results accumulate indefinitely
// and cause OOM (old_space exhaustion).
// ToolResult, Retry, Notification are excluded: ToolResult fires
// on every tool-call return (O(history) overhead per call), and
// mid-loop compaction could blank results the model still needs.
const shouldCompact =
if (
messageType === SendMessageType.UserQuery ||
messageType === SendMessageType.Cron ||
messageType === SendMessageType.Hook;
if (shouldCompact) {
try {
const mcResult = microcompactHistory(
this.getHistoryShallow(),
this.lastApiCompletionTimestamp,
this.config.getClearContextOnIdle(),
);
if (mcResult.meta) {
const m = mcResult.meta;
this.getChat().setHistory(mcResult.history);
// Disarm only the blanked files' fast-path, keeping
// read-before-write state intact (issue #4239; rationale on
// FileReadEntry.readResidentInHistory). Any blanked read we
// can't disarm surgically forces the old blanket wipe so a
// later Read can't get a dangling file_unchanged placeholder.
const fileReadCache = this.config.getFileReadCache();
if (m.unresolvedEvictedReads > 0) {
debugLogger.debug(
`[FILE_READ_CACHE] clear after microcompaction ` +
`(${m.unresolvedEvictedReads} unresolved blanked read(s))`,
);
fileReadCache.clear();
} else {
// Concurrent stats — don't serialize N FS round-trips
// before the next turn.
const statResults = await Promise.all(
m.evictedReadPaths.map((p) =>
fsPromises.stat(p).catch(() => undefined),
),
);
// A path is surgically disarmed only if it stats AND its
// inode matches the recorded entry. A failed stat or inode
// miss could leave a stale entry armed, so fall back to the
// blanket wipe if any path is unresolvable.
let fullyDisarmed = true;
for (const stats of statResults) {
if (
!stats ||
!fileReadCache.markReadEvictedFromHistory(stats)
) {
fullyDisarmed = false;
}
}
if (fullyDisarmed) {
debugLogger.debug(
`[FILE_READ_CACHE] disarmed fast-path for ` +
`${m.evictedReadPaths.length} file(s) after microcompaction`,
);
} else {
debugLogger.debug(
'[FILE_READ_CACHE] clear after microcompaction ' +
'(an evicted path was unresolvable)',
);
fileReadCache.clear();
}
}
debugLogger.debug(
`[TIME-BASED MC] gap ${m.gapMinutes}min > ${m.thresholdMinutes}min, ` +
`cleared ${m.toolsCleared} tool result(s) + ${m.mediaCleared} media (~${m.tokensSaved} tokens), ` +
`kept ${m.toolsKept} tool / ${m.mediaKept} media`,
);
}
} catch (err) {
debugLogger.error(
`[TIME-BASED MC] microcompactHistory failed: ${err instanceof Error ? err.message : String(err)}`,
);
messageType === SendMessageType.Cron
) {
// Idle cleanup: clear old tool results when idle > threshold.
// Runs on user and cron messages. ToolResult and Retry are
// excluded; Hook continuations use a separate checkpoint below.
const compacted = await this.microcompactIdleHistory(
this.lastApiCompletionTimestamp,
);
if (messageType === SendMessageType.UserQuery || compacted) {
this.lastHookMicrocompactionTimestamp = Date.now();
}
} else if (messageType === SendMessageType.Hook) {
this.lastHookMicrocompactionTimestamp ??=
this.lastApiCompletionTimestamp ?? Date.now();
const checkpoint = this.lastHookMicrocompactionTimestamp;
if (await this.microcompactIdleHistory(checkpoint)) {
this.lastHookMicrocompactionTimestamp = Date.now();
}
}

View file

@ -120,7 +120,7 @@ When the user asks about configuration, the primary reference is `docs/configura
| Permissions | `permissions.allow/ask/deny` | `docs/configuration/settings.md`, `docs/features/approval-mode.md` |
| MCP Servers | `mcpServers.*`, `mcp.*` | `docs/configuration/settings.md`, `docs/features/mcp.md` |
| Tool Approval | `tools.approvalMode` | `docs/configuration/settings.md`, `docs/features/approval-mode.md`, `docs/features/auto-mode.md` |
| Hooks | `hooks.*` | `docs/configuration/settings.md`, `docs/features/hooks.md` |
| Hooks | `hooks.*` | `docs/configuration/settings.md`, `docs/features/hooks.md` |
| Model | `model.name`, `modelProviders` | `docs/configuration/settings.md`, `docs/configuration/model-providers.md` |
| General/UI | `general.*`, `ui.*`, `ide.*`, `output.*` | `docs/configuration/settings.md` |
| Context | `context.*` | `docs/configuration/settings.md` |