Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d93a758
feat(core): reject upstream fail-fast placeholder responses
yiliang114 Aug 11, 2026
9b6de2e
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Aug 12, 2026
6bded47
Merge branch 'main' into feat/8916-degraded-placeholder-defense
wenshao Aug 12, 2026
c504077
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Aug 12, 2026
9b06525
fix(core): prevent placeholder text from reaching display before retr…
yiliang114 Aug 13, 2026
aa2572c
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Aug 13, 2026
4767a29
fix(core): block degraded continuation leaks
yiliang114 Aug 13, 2026
bf107c7
fix(core): catch split degraded placeholders
yiliang114 Aug 13, 2026
121ba97
fix(core): withhold degraded placeholder chunks
yiliang114 Aug 14, 2026
e8dc46f
fix(core): preserve current image payloads after placeholder curation
yiliang114 Aug 14, 2026
0d37451
fix(core): preserve deferred stream chunks safely
yiliang114 Aug 14, 2026
a5adb37
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Aug 14, 2026
b967f8e
fix(core): fold held placeholder-prefix text into continuation accoun…
yiliang114 Aug 14, 2026
60891f5
fix(core): latch deferredFirstChunk so duplicate finishReason chunks …
yiliang114 Aug 14, 2026
387f918
fix(core): judge deferred chunk against accumulated text on stream error
yiliang114 Aug 14, 2026
2a2d5db
fix(core): deliver withheld placeholder-defense chunks in arrival ord…
yiliang114 Aug 14, 2026
6f42821
fix(core): propagate image eviction to durable history, harden placeh…
yiliang114 Aug 14, 2026
1330183
fix(core): preserve deferred stream metadata
yiliang114 Aug 15, 2026
a1e5b4b
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Aug 15, 2026
5733389
fix(core): close round-12 findings on degraded-placeholder defense
yiliang114 Aug 15, 2026
250dd3e
fix(core): close round-13 findings on degraded-placeholder defense
yiliang114 Aug 15, 2026
1c0be41
test(core): pin full history in the second tool-result continuation test
yiliang114 Aug 15, 2026
56068cd
fix(core): read the converted placeholder error type in budget telemetry
yiliang114 Aug 15, 2026
e2137cd
fix(core): exempt last-referenced images from reattach cap + harden t…
yiliang114 Aug 15, 2026
aad6c11
refactor(core): dedupe placeholder-error and reattach-append sites
yiliang114 Aug 15, 2026
c6d1969
fix(core): gate placeholder conversion on !streamYieldedFunctionCall …
yiliang114 Aug 15, 2026
0c78052
test(core): attach the rejection handler before timer flush (#8938)
yiliang114 Aug 15, 2026
8cce5d2
test(core): satisfy vitest/valid-expect on the pre-timer rejection ha…
yiliang114 Aug 15, 2026
7974050
fix(core): scope placeholder run invalidation to the degraded turns (…
yiliang114 Aug 16, 2026
74bc08d
Merge remote-tracking branch 'origin/main' into feat/8916-degraded-pl…
yiliang114 Aug 18, 2026
e281211
refactor(core): narrow degraded placeholder defense
yiliang114 Aug 18, 2026
74211a0
fix(core): preserve placeholder-aware tool adjacency
yiliang114 Aug 18, 2026
f1e647a
Merge branch 'main' into feat/8916-degraded-placeholder-defense
yiliang114 Aug 19, 2026
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
264 changes: 264 additions & 0 deletions packages/core/src/core/geminiChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2715,6 +2715,47 @@ describe('GeminiChat', async () => {
);
});

it('excludes exact degraded placeholders without dropping legitimate mentions or tool calls', () => {
const toolCall = {
functionCall: { id: 'call-1', name: 'read_file', args: {} },
};
const functionResponse = {
functionResponse: {
id: 'call-1',
name: 'read_file',
response: { output: 'ok' },
},
};
const history: Content[] = [
{ role: 'user', parts: [{ text: 'what happened?' }] },
{
role: 'model',
parts: [{ text: 'The endpoint returned (request timeout) once.' }],
},
{ role: 'user', parts: [{ text: 'continue' }] },
{ role: 'model', parts: [{ text: ' (request timeout) ' }] },
{ role: 'user', parts: [{ text: 'try again' }] },
{
role: 'model',
parts: [{ text: '(request timeout)' }, toolCall],
},
{ role: 'user', parts: [functionResponse] },
];
chat.setHistory(history);

expect(chat.getHistory(true)).toEqual([
history[0],
history[1],
{
role: 'user',
parts: [{ text: 'continue' }, { text: 'try again' }],
},
history[5],
history[6],
]);
expect(chat.getHistory()).toEqual(history);
});

it('should not update global telemetry when no telemetryService is provided (subagent isolation)', async () => {
// Simulate a subagent GeminiChat: created without a telemetryService
const subagentChat = new GeminiChat(mockConfig, config, []);
Expand Down Expand Up @@ -5927,6 +5968,120 @@ describe('GeminiChat', async () => {
}
});

it('retries a split degraded placeholder without yielding or persisting it', async () => {
vi.useFakeTimers();
try {
vi.mocked(mockContentGenerator.generateContentStream)
.mockResolvedValueOnce(
streamResponse(
{
candidates: [{ content: { parts: [{ text: '(request ' }] } }],
} as unknown as GenerateContentResponse,
stopResponse([{ text: 'timeout)' }]),
),
)
.mockResolvedValueOnce(
streamResponse(stopResponse([{ text: 'Recovered response' }])),
);

const stream = await chat.sendMessageStream(
'test-model',
{ message: 'test' },
'prompt-id-degraded-placeholder',
);
const events = await collectStreamWithFakeTimers(stream);
const emitted = events
.filter((event) => event.type === StreamEventType.CHUNK)
.flatMap(
(event) =>
event.value.candidates?.[0]?.content?.parts?.map(
(part) => part.text,
) ?? [],
);

expect(emitted).toEqual(['Recovered response']);
expect(
mockContentGenerator.generateContentStream,
).toHaveBeenCalledTimes(2);
expect(mockLogContentRetry).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
error_type: 'UPSTREAM_DEGRADED_RESPONSE',
}),
);
expect(chat.getHistory()).toEqual([
{ role: 'user', parts: [{ text: 'test' }] },
{ role: 'model', parts: [{ text: 'Recovered response' }] },
]);
} finally {
vi.useRealTimers();
}
});

it('passes through longer text that mentions the placeholder', async () => {
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
streamResponse(
stopResponse([
{ text: 'The endpoint returned (request timeout) once.' },
]),
),
);

const stream = await chat.sendMessageStream(
'test-model',
{ message: 'test' },
'prompt-id-placeholder-mention',
);
const events: StreamEvent[] = [];
for await (const event of stream) events.push(event);

expect(
events.some(
(event) =>
event.type === StreamEventType.CHUNK &&
event.value.candidates?.[0]?.content?.parts?.[0]?.text ===
'The endpoint returned (request timeout) once.',
),
).toBe(true);
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
1,
);
});

it('does not reject a placeholder turn that contains a function call', async () => {
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
streamResponse(
stopResponse([
{ text: '(request timeout)' },
{
functionCall: { id: 'call-1', name: 'read_file', args: {} },
},
]),
),
);

const stream = await chat.sendMessageStream(
'test-model',
{ message: 'test' },
'prompt-id-placeholder-tool-call',
);
const events: StreamEvent[] = [];
for await (const event of stream) events.push(event);

expect(
events.some(
(event) =>
event.type === StreamEventType.CHUNK &&
event.value.candidates?.[0]?.content?.parts?.some(
(part) => part.functionCall?.id === 'call-1',
),
),
).toBe(true);
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
1,
);
});

it('should fail after all retries on persistent invalid content and report metrics', async () => {
vi.useFakeTimers();
try {
Expand Down Expand Up @@ -7515,6 +7670,55 @@ describe('GeminiChat', async () => {
}
});

it('replays after a transport cut without leaking a placeholder prefix', async () => {
vi.useFakeTimers();
try {
const transportError = Object.assign(new TypeError('terminated'), {
cause: Object.assign(new Error('other side closed'), {
code: 'UND_ERR_SOCKET',
}),
});
vi.mocked(mockContentGenerator.generateContentStream)
.mockResolvedValueOnce(
(async function* () {
yield {
candidates: [{ content: { parts: [{ text: '(request ' }] } }],
} as unknown as GenerateContentResponse;
throw transportError;
})(),
)
.mockResolvedValueOnce(
streamResponse(stopResponse([{ text: 'Recovered response' }])),
);

const stream = await chat.sendMessageStream(
'test-model',
{ message: 'test' },
'prompt-placeholder-prefix-transport-cut',
);
const events = await collectStreamWithFakeTimers(stream, 5_000);
const emittedText = events
.filter((event) => event.type === StreamEventType.CHUNK)
.flatMap(
(event) =>
event.value.candidates?.[0]?.content?.parts?.map(
(part) => part.text,
) ?? [],
);

expect(emittedText).toEqual(['Recovered response']);
expect(
events.filter((event) => event.type === StreamEventType.RETRY),
).toHaveLength(1);
expect(chat.getHistory()).toEqual([
{ role: 'user', parts: [{ text: 'test' }] },
{ role: 'model', parts: [{ text: 'Recovered response' }] },
]);
} finally {
vi.useRealTimers();
}
});

it('stops retrying retryable transport stream errors after the retry budget is exhausted', async () => {
vi.useFakeTimers();
try {
Expand Down Expand Up @@ -11999,6 +12203,66 @@ describe('GeminiChat', async () => {
// (`--resume` of a crashed session, Ctrl+Y before in-flight tool
// finishes, scheduler abort before submitQuery, manual JSONL edits).

it('keeps a tool result adjacent across a removable degraded placeholder', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'open /tmp/a.txt' }] },
{
role: 'model',
parts: [
{
functionCall: {
id: 'call-1',
name: 'read_file',
args: { path: '/tmp/a.txt' },
},
},
],
},
{ role: 'model', parts: [{ text: '(request timeout)' }] },
{
role: 'user',
parts: [
{
functionResponse: {
id: 'call-1',
name: 'read_file',
response: { output: 'ok' },
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'call-1',
name: 'read_file',
response: { output: 'ok' },
},
},
],
},
];
const expectedHistory = structuredClone(history.slice(0, 4));
chat.setHistory(history);

expect(chat.repairOrphanedToolUseTurns()).toEqual({
injected: [],
droppedDuplicates: [{ callId: 'call-1', name: 'read_file' }],
});
expect(chat.repairOrphanedToolUseTurns()).toEqual({
injected: [],
droppedDuplicates: [],
});
expect(chat.getHistory()).toEqual(expectedHistory);
expect(chat.getHistory(true)).toEqual([
expectedHistory[0],
expectedHistory[1],
expectedHistory[3],
]);
});

it('injects a synthetic functionResponse for a trailing tool_use (Race B/C)', () => {
// --resume of a session that crashed after the partial-tool_use push
// in `processStreamResponse` but before the scheduler submitted the
Expand Down
Loading
Loading