Skip to content
52 changes: 52 additions & 0 deletions packages/core/src/followup/speculation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,58 @@ describe('startSpeculation', () => {
});
});

describe('generatePipelinedSuggestion preserveTools', () => {
it('passes preserveTools: true to runForkedAgent', async () => {
const config = {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getCwd: vi.fn().mockReturnValue(process.cwd()),
getFastModel: vi.fn().mockReturnValue(undefined),
getToolRegistry: vi.fn().mockReturnValue({
ensureTool: vi.fn().mockResolvedValue({
build: vi.fn().mockReturnValue({
execute: vi.fn().mockResolvedValue({
llmContent: '',
returnDisplay: '',
}),
}),
}),
}),
} as unknown as Config;

forkedAgentMocks.runForkedAgent.mockResolvedValue({
jsonResult: { suggestion: 'next step' },
});

// Model returns text-only — no tool calls — so speculation completes
// and triggers generatePipelinedSuggestion.
forkedAgentMocks.sendMessageStream.mockImplementation(async function* () {
yield {
type: 'chunk',
value: {
candidates: [
{
content: {
parts: [{ text: 'done' }],
},
},
],
},
};
});

const state = await startSpeculation(config, 'do something');
await vi.waitFor(() => {
expect(state.status).toBe('completed');
});

expect(forkedAgentMocks.runForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({ preserveTools: true }),
);

await abortSpeculation(state);
});
});

describe('ensureToolResultPairing', () => {
it('returns empty array unchanged', () => {
expect(ensureToolResultPairing([])).toEqual([]);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/followup/speculation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ ${SUGGESTION_PROMPT}`;
jsonSchema: PIPELINED_SCHEMA,
...(model !== undefined ? { model } : {}),
abortSignal,
preserveTools: true,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 2ab6f80 — new test 'passes preserveTools: true to runForkedAgent' in speculation.test.ts. The test runs a text-only speculation to completion (triggering generatePipelinedSuggestion), then asserts runForkedAgent was called with preserveTools: true. All 9 tests in the file pass.

});

if (abortSignal.aborted) return null;
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/followup/suggestionGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,34 @@ describe('generatePromptSuggestion', () => {
expect.objectContaining({ model: 'openai:fast-model' }),
);
});
it('passes preserveTools: true for Anthropic prompt-cache sharing', async () => {
mockGetCacheSafeParams.mockReturnValue({
generationConfig: {},
history: conversationHistory,
model: 'main-model',
version: 1,
});
mockRunForkedAgent.mockResolvedValue({
text: null,
jsonResult: { suggestion: 'run tests' },
usage: { inputTokens: 10, outputTokens: 3, cacheHitTokens: 5 },
});
const config = {
getFastModel: vi.fn(() => undefined),
getModel: vi.fn(() => 'main-model'),
} as unknown as Config;

await generatePromptSuggestion(
config,
conversationHistory,
new AbortController().signal,
{ enableCacheSharing: true },
);

expect(mockRunForkedAgent).toHaveBeenCalledWith(
expect.objectContaining({ preserveTools: true }),
);
});
});

describe('shouldFilterSuggestion', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/followup/suggestionGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ async function generateViaForkedQuery(
cacheSafeParams,
jsonSchema: SUGGESTION_SCHEMA,
model,
preserveTools: true,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 8d53a0e:

  • suggestionGenerator.ts: changed to preserveTools: model === cacheSafeParams.model
  • speculation.ts: introduced resolvedModel = model ?? cacheSafeParams.model, then preserveTools: resolvedModel === cacheSafeParams.model
  • Added tests in both suggestionGenerator.test.ts and speculation.test.ts verifying preserveTools: false when getFastModel() returns a model different from cacheSafeParams.model

All 49 tests pass.

});

if (result.jsonResult) {
Expand Down
257 changes: 257 additions & 0 deletions packages/core/src/utils/forkedAgent.cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,263 @@ describe('runForkedAgent (cache path)', () => {
expect(createRuntimeContentGeneratorView).not.toHaveBeenCalled();
});

it('does not strip tools when preserveTools is true', async () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 2ab6f80 — new test 'filters out functionCall parts when preserveTools is true' in forkedAgent.cache.test.ts. The test mocks a stream chunk containing both a text part and a functionCall part, calls runForkedAgent with preserveTools: true, and asserts only the text content is returned. All 17 tests in the file pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 8d53a0e — new test preserves tools and includes jsonSchema fields when both preserveTools and jsonSchema are set in forkedAgent.cache.test.ts.

The test:

  1. Saves cacheSafeParams with tools
  2. Calls runForkedAgent with both preserveTools: true and a jsonSchema
  3. Captures the params passed to sendMessageStream
  4. Asserts config.tools is undefined (tools not stripped — preserveTools working)
  5. Asserts config.responseMimeType === "application/json"
  6. Asserts config.responseJsonSchema matches the passed schema

All 49 tests pass (18 forkedAgent.cache + 21 suggestionGenerator + 10 speculation).

saveCacheSafeParams(
{
systemInstruction: 'You are helpful',
tools: [
{
functionDeclarations: [
{ name: 'edit', description: 'Edit a file' },
{ name: 'shell', description: 'Run a command' },
],
},
],
},
[{ role: 'user', parts: [{ text: 'hello' }] }],
'test-model',
);

let capturedParams: unknown = null;

const mockSendMessageStream = vi.fn(
(_model: string, params: unknown, _promptId: string) => {
capturedParams = params;
async function* generate() {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
role: 'model',
parts: [{ text: '{"suggestion":"run tests"}' }],
},
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 5,
},
},
};
}
return Promise.resolve(generate());
},
);

vi.mocked(GeminiChat).mockImplementation(
() =>
({
sendMessageStream: mockSendMessageStream,
}) as unknown as GeminiChat,
);

await runForkedAgent({
config: {} as Config,
userMessage: 'suggest something',
cacheSafeParams: getCacheSafeParams()!,
preserveTools: true,
});

const sendParams = capturedParams as {
config?: { tools?: unknown };
};
expect(sendParams.config!.tools).toBeUndefined();
});

it('strips tools when preserveTools is explicitly false', async () => {
saveCacheSafeParams(
{
tools: [
{
functionDeclarations: [
{ name: 'edit', description: 'Edit a file' },
],
},
],
},
[],
'test-model',
);

let capturedParams: unknown = null;

const mockSendMessageStream = vi.fn(
(_model: string, params: unknown, _promptId: string) => {
capturedParams = params;
async function* generate() {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
role: 'model',
parts: [{ text: 'ok' }],
},
},
],
},
};
}
return Promise.resolve(generate());
},
);

vi.mocked(GeminiChat).mockImplementation(
() =>
({
sendMessageStream: mockSendMessageStream,
}) as unknown as GeminiChat,
);

await runForkedAgent({
config: {} as Config,
userMessage: 'suggest something',
cacheSafeParams: getCacheSafeParams()!,
preserveTools: false,
});

const sendParams = capturedParams as {
config?: { tools?: unknown };
};
expect(sendParams.config!.tools).toEqual([]);
});

it('filters out functionCall parts when preserveTools is true', async () => {
saveCacheSafeParams(
{
systemInstruction: 'You are helpful',
tools: [
{
functionDeclarations: [
{ name: 'edit', description: 'Edit a file' },
],
},
],
},
[],
'test-model',
);

const mockSendMessageStream = vi.fn(
(_model: string, _params: unknown, _promptId: string) => {
async function* generate() {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
role: 'model',
parts: [
{ text: 'some text' },
{
functionCall: {
name: 'edit',
args: { file: 'a.ts' },
},
},
],
},
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 5,
totalTokenCount: 15,
},
},
};
}
return Promise.resolve(generate());
},
);

vi.mocked(GeminiChat).mockImplementation(
() =>
({
sendMessageStream: mockSendMessageStream,
}) as unknown as GeminiChat,
);

const result = await runForkedAgent({
config: {} as Config,
userMessage: 'suggest something',
cacheSafeParams: getCacheSafeParams()!,
preserveTools: true,
});

expect(result.text).toBe('some text');
});

it('returns null text when response contains only functionCall parts', async () => {
saveCacheSafeParams(
{
systemInstruction: 'You are helpful',
tools: [
{
functionDeclarations: [
{ name: 'edit', description: 'Edit a file' },
],
},
],
},
[],
'test-model',
);

const mockSendMessageStream = vi.fn(
(_model: string, _params: unknown, _promptId: string) => {
async function* generate() {
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
role: 'model',
parts: [
{
functionCall: {
name: 'edit',
args: { file: 'a.ts' },
},
},
],
},
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 5,
totalTokenCount: 15,
},
},
};
}
return Promise.resolve(generate());
},
);

vi.mocked(GeminiChat).mockImplementation(
() =>
({
sendMessageStream: mockSendMessageStream,
}) as unknown as GeminiChat,
);

const result = await runForkedAgent({
config: {} as Config,
userMessage: 'suggest something',
cacheSafeParams: getCacheSafeParams()!,
preserveTools: true,
});

expect(result.text).toBeNull();
});

it('throws when CacheSafeParams are not available', async () => {
const mockConfig = {} as unknown as Config;

Expand Down
Loading
Loading