Skip to content
Merged
5 changes: 4 additions & 1 deletion packages/cli/src/commands/extensions/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/extensions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
12 changes: 3 additions & 9 deletions packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down Expand Up @@ -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<
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
296 changes: 296 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 () => {

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] This test covers the happy path (pre-set stale timestamp → trigger fires → cleanup runs), but several important paths are untested:

  1. ??= initialization: when lastHookMicrocompactionTimestamp is null and lastApiCompletionTimestamp is an old value, the Hook should inherit that timestamp and potentially trigger. This is the most logic-dense line in the change.
  2. Negative test: set lastHookMicrocompactionTimestamp = Date.now() (recent), send a Hook message, and assert setHistory is NOT called — guards against unconditional microcompaction on every Hook.
  3. Both-null fallback: both timestamps null → should use Date.now() and NOT trigger.
  4. Timestamp reset: after microcompaction fires, assert lastHookMicrocompactionTimestamp is updated to a recent value.

Adding at minimum the negative test and the ??= init test would significantly strengthen coverage for the subtle parts of this change.

— qwen3.7-max via Qwen Code /review

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
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading