Skip to content
Closed
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
112 changes: 94 additions & 18 deletions packages/core/src/core/geminiChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ import {
type HistoryTurn,
coalesceConsecutiveRoles,
stripThoughts,
applyRetryNudge,
THINKING_ONLY_NUDGE_MESSAGE,
NO_RESPONSE_TEXT_NUDGE_MESSAGE,
applyRetryNudge,
} from './geminiChat.js';
import {
type CompletedToolCall,
Expand Down Expand Up @@ -2768,12 +2768,18 @@ describe('GeminiChat', () => {
2,
);

// First call should have original system instruction
// First call should have original system instruction and original user prompt
expect(
mockContentGenerator.generateContentStream,
).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
contents: expect.arrayContaining([
expect.objectContaining({
role: 'user',
parts: [{ text: 'test' }],
}),
]),
config: expect.objectContaining({
systemInstruction: 'Initial instruction',
}),
Expand All @@ -2788,9 +2794,6 @@ describe('GeminiChat', () => {
).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
config: expect.objectContaining({
systemInstruction: 'Initial instruction',
}),
contents: [
expect.objectContaining({
role: 'user',
Expand All @@ -2800,6 +2803,9 @@ describe('GeminiChat', () => {
],
}),
],
config: expect.objectContaining({
systemInstruction: 'Initial instruction',
}),
}),
'prompt-id-retry-nudge',
LlmRole.MAIN,
Expand Down Expand Up @@ -2892,6 +2898,74 @@ describe('GeminiChat', () => {
);
});

it('should append NO_RESPONSE_TEXT nudge message to contents on retry', async () => {
vi.mocked(mockContentGenerator.generateContentStream)
.mockImplementationOnce(async () =>
(async function* () {
yield {
candidates: [
{
content: {
role: 'model',
parts: [{ text: '' }],
},
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
)
.mockImplementationOnce(async () =>
(async function* () {
yield {
candidates: [
{
content: {
parts: [{ text: 'response after empty recovery' }],
},
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);

const stream = await chat.sendMessageStream(
{ model: 'gemini-2.5-pro' },
'ping',
'prompt-id-retry-empty-text',
new AbortController().signal,
LlmRole.MAIN,
);

for await (const _ of stream) {
// consume
}

expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
2,
);

expect(
mockContentGenerator.generateContentStream,
).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
contents: expect.arrayContaining([
expect.objectContaining({
role: 'user',
parts: expect.arrayContaining([
{ text: 'ping' },
{ text: `\n${NO_RESPONSE_TEXT_NUDGE_MESSAGE}` },
]),
}),
]),
}),
'prompt-id-retry-empty-text',
LlmRole.MAIN,
);
});

it('should fail after all retries on persistent invalid content and report metrics', async () => {
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async () =>
Expand Down Expand Up @@ -5005,31 +5079,33 @@ describe('GeminiChat', () => {
]);
});

it('should insert synthetic model turn and dedicated user turn if the last turn is user with functionResponse', () => {
it('should handle user turn with functionResponse correctly', () => {
const contents: Content[] = [
{
role: 'user',
parts: [
{
functionResponse: {
name: 'Edit',
response: { result: 'success' },
name: 'test_tool',
response: { output: 'result' },
},
},
],
},
];
const result = applyRetryNudge(contents, NO_RESPONSE_TEXT_NUDGE_MESSAGE);
expect(result).toHaveLength(3);
expect(result[0]).toEqual(contents[0]);
expect(result[1].role).toBe('model');
expect(result[1].parts).toEqual([
{ text: '[Tool execution completed.]' },
]);
expect(result[2].role).toBe('user');
expect(result[2].parts).toEqual([
{ text: NO_RESPONSE_TEXT_NUDGE_MESSAGE },
]);

expect(result).toHaveLength(1);
expect(result[0].parts).toHaveLength(2);
expect(result[0].parts?.[0]).toEqual({
functionResponse: {
name: 'test_tool',
response: { output: 'result' },
},
});
expect(result[0].parts?.[1]).toEqual({
text: `\n${NO_RESPONSE_TEXT_NUDGE_MESSAGE}`,
});
});

it('should not duplicate the nudge message if it is already present in contents', () => {
Expand Down
17 changes: 1 addition & 16 deletions packages/core/src/core/geminiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,22 +275,7 @@ export function applyRetryNudge(
}));

const clonedLastTurn = cloned[cloned.length - 1];
const hasFunctionResponse = clonedLastTurn?.parts?.some(
(p) => p.functionResponse,
);

if (clonedLastTurn?.role === 'user' && hasFunctionResponse) {
// Satisfy strict role alternation invariants of the Gemini API by inserting
// a neutral, synthetic model turn between the tool response and the nudge prompt.
cloned.push({
role: 'model',
parts: [{ text: '[Tool execution completed.]' }],
});
cloned.push({
role: 'user',
parts: [{ text: nudgeMessage }],
});
} else if (clonedLastTurn?.role === 'user') {
if (clonedLastTurn?.role === 'user') {
if (!clonedLastTurn.parts) {
clonedLastTurn.parts = [];
}
Expand Down