fix(core): avoid false recovery after directory changes (#15012)

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-10-22 10:38:24 +08:00
1 parent 9a913c7302
commit 257b9f5413
11 files changed
+435 -10

No files matched your search

+47
View File
@@ -36,6 +36,7 @@ import {
type UserPromptAdmission,
} from './client.js';
import { MESSAGE_DISPLAY_DEBOUNCE_MS } from './message-display-buffer.js';
import { detectTurnInterruption } from './turn-interruption.js';
import { getRecentGitStatus } from '../utils/gitUtils.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
import {
@@ -121,6 +122,7 @@ import {
buildChangedMcpToolsReminder,
buildChangedSkillsReminder,
buildMcpServerInstructionsReminderFromEntries,
getDirectoryContextString,
getInitialChatHistory,
wrapSystemReminder,
} from './environmentContext.js';
@@ -3709,6 +3711,51 @@ describe('Gemini Client (client.ts)', () => {
});
});
it.each([null, 'Git snapshot'])(
'does not mistake a working-directory refresh for interrupted input (%s)',
async (gitStatus) => {
await client.startChat();
vi.mocked(getDirectoryContextString).mockResolvedValue(
'Mocked directory context',
);
vi.mocked(getRecentGitStatus).mockReturnValue(gitStatus);
const history: Content[] = [userText('review'), modelText('done')];
vi.spyOn(client.getChat(), 'addHistory').mockImplementation((entry) => {
history.push(entry);
});
await client.addWorkingDirectoryChangedContext('/old', '/new');
const serialized = JSON.parse(JSON.stringify(history)) as Content[];
expect(serialized.slice(2)).toEqual([
{
role: 'user',
parts: [
{
text:
"The session's working directory has changed from /old to /new via /cd. " +
'The startup directory context above is stale. All tool calls and relative paths now resolve from /new.',
},
...(gitStatus
? [
{
text: `<system-reminder>\n${gitStatus}\n</system-reminder>`,
},
]
: []),
],
},
userText('Mocked directory context'),
]);
expect(detectTurnInterruption(history).kind).toBe('none');
// The same words submitted as real input must still be recoverable.
expect(detectTurnInterruption(serialized)).toEqual({
kind: 'interrupted_prompt',
parts: serialized.slice(2).flatMap((entry) => entry.parts ?? []),
});
},
);
describe('getMainSessionSystemInstruction', () => {
it('does not collect local git or agent context for an execution environment', async () => {
vi.mocked(mockConfig.getExecutionEnvironment).mockReturnValue(
+8 -4
View File
@@ -188,6 +188,7 @@ import {
import type { DeferredToolSummary } from '../tools/tool-registry.js';
import { replayUiTelemetryFromConversation } from '../services/sessionService.js';
import { buildSessionHistoryFromConversation } from '../services/session-api-history.js';
import { markApiHistoryDirectoryContext } from '../utils/api-history-metadata.js';
import { reportError } from '../utils/errorReporting.js';
import {
getErrorMessage,
@@ -2019,10 +2020,12 @@ export class LlmClient {
return;
}
this.getChat().addHistory({
const chat = this.getChat();
const context: Content = {
role: 'user',
parts: [{ text: await getDirectoryContextString(this.config) }],
});
};
chat.addHistory(markApiHistoryDirectoryContext(context));
}
async addWorkingDirectoryChangedContext(
@@ -2040,7 +2043,7 @@ export class LlmClient {
this.cachedGitStatus = undefined;
await this.refreshSystemInstruction();
const gitStatus = this.getCachedGitStatus();
this.getChat().addHistory({
const context: Content = {
role: 'user',
parts: [
{
@@ -2050,7 +2053,8 @@ export class LlmClient {
},
...(gitStatus ? [{ text: wrapSystemReminder(gitStatus) }] : []),
],
});
};
this.getChat().addHistory(markApiHistoryDirectoryContext(context));
await this.addDirectoryContext();
}
+165
View File
@@ -80,6 +80,10 @@ import { SYSTEM_REMINDER_OPEN } from './environmentContext.js';
import { selectForkHistory } from '../tools/agent/fork-subagent.js';
import { composePostCompactHistory } from '../services/postCompactAttachments.js';
import { detectTurnInterruption } from './turn-interruption.js';
import {
markApiHistoryDirectoryContext,
markApiHistoryPrompt,
} from '../utils/api-history-metadata.js';
import { formatAgentMessageModelText } from '../agents/session-agents/envelope.js';
import { SessionStartSource } from '../hooks/types.js';
import * as sideQueryModule from '../utils/sideQuery.js';
@@ -5092,6 +5096,167 @@ describe('LlmChat', async () => {
throw new Error('unexpected deep clone');
});
describe('directory context recovery', () => {
it.each([false, true])(
'only replays context after a structural reminder when real input is pending (%s)',
(realInput) => {
const input = realInput ? [userText('Unanswered real input')] : [];
const contexts = [
markApiHistoryDirectoryContext(userText('Directory changed.')),
markApiHistoryDirectoryContext(userText('Directory structure.')),
];
const history = [
userText('review'),
modelText('done'),
userText('<system-reminder>\nNew MCP tools.\n</system-reminder>'),
...input,
...contexts,
];
const serialized = JSON.stringify(history);
chat.setHistory(history);
expect(detectTurnInterruption(chat.getHistory())).toEqual(
realInput
? {
kind: 'interrupted_prompt',
parts: [...input, ...contexts].flatMap(
(entry) => entry.parts ?? [],
),
}
: { kind: 'none' },
);
expect(JSON.stringify(chat.getHistory())).toBe(serialized);
},
);
it.each(['before', 'after'])(
'replays exactly the entries strip removes when real input is %s directory context',
(position) => {
const contexts = [
markApiHistoryDirectoryContext(userText('Directory changed.')),
markApiHistoryDirectoryContext(userText('Directory structure.')),
];
const input = userText('Unanswered real input');
const entries =
position === 'before' ? [input, ...contexts] : [...contexts, input];
const kept = [
userText('review'),
modelText('done'),
userText('<system-reminder>\nNew MCP tools.\n</system-reminder>'),
];
chat.setHistory([...kept, ...entries]);
const interruption = detectTurnInterruption(chat.getHistory());
expect(interruption.kind).toBe('interrupted_prompt');
const stripped = chat.stripOrphanedUserEntriesFromHistory();
expect(stripped).toEqual(entries);
if (interruption.kind === 'interrupted_prompt') {
expect(interruption.parts).toEqual(
stripped.flatMap((entry) => entry.parts ?? []),
);
}
expect(chat.getHistory()).toEqual(kept);
},
);
it('recovers tool results repaired into a directory entry without duplicating them', () => {
chat.setHistory([
userText('review'),
content('model', fnCall('read_file', {}, 'pending')),
markApiHistoryDirectoryContext(userText('Directory changed.')),
markApiHistoryDirectoryContext(userText('Directory structure.')),
]);
expect(chat.repairOrphanedToolUseTurns().injected).toHaveLength(1);
const interruption = detectTurnInterruption(chat.getHistory());
expect(interruption.kind).toBe('interrupted_prompt');
if (interruption.kind === 'interrupted_prompt') {
expect(
interruption.parts.filter((part) => part.functionResponse),
).toEqual([
expect.objectContaining({
functionResponse: expect.objectContaining({ id: 'pending' }),
}),
]);
}
expect(chat.repairOrphanedToolUseTurns().injected).toEqual([]);
});
it.each([false, true])(
'only keeps a merged context marker when all entries are internal (%s)',
(realInput) => {
const following = userText('Following text');
if (!realInput) markApiHistoryDirectoryContext(following);
chat.setHistory([
userText('review'),
modelText('done'),
markApiHistoryDirectoryContext(userText('Directory context')),
following,
]);
expect(detectTurnInterruption(chat.getHistory(true)).kind).toBe(
realInput ? 'interrupted_prompt' : 'none',
);
},
);
it.each(['clean', 'prompt-before', 'prompt-after', 'tool'] as const)(
'preserves the %s verdict through recovery copies without changing history',
(scenario) => {
const directories = Array.from({ length: 60 }, (_, index) =>
markApiHistoryDirectoryContext(
userText(`Directory context ${index}`),
),
);
const input = userText('Unanswered real input');
const history = [userText('review'), modelText('done')];
if (scenario === 'prompt-before') history.push(input);
if (scenario === 'tool') {
history.push(content('model', fnCall('read_file', {}, 'pending')));
}
history.push(...directories);
if (scenario === 'prompt-after') history.push(input);
const serialized = JSON.stringify(history);
chat.setHistory(history);
for (const [index, copy] of [
chat.getHistory(),
chat.getHistoryShallow(),
chat.getHistoryForRecovery(50),
].entries()) {
const boundedPrompt = index === 2 && scenario === 'prompt-after';
expect(JSON.stringify(copy)).toBe(
boundedPrompt ? JSON.stringify(history.slice(-50)) : serialized,
);
const result = detectTurnInterruption(copy);
if (scenario === 'clean') {
expect(result.kind).toBe('none');
} else if (scenario === 'tool') {
expect(result).toEqual({
kind: 'interrupted_turn',
danglingCalls: [{ callId: 'pending', name: 'read_file' }],
});
} else {
expect(result).toEqual({
kind: 'interrupted_prompt',
parts: history
.slice(boundedPrompt ? -50 : 2)
.flatMap((entry) => entry.parts ?? []),
});
}
}
},
);
it('keeps real input recoverable even if it reuses a marked entry', () => {
const input = markApiHistoryDirectoryContext(
userText('Directory context'),
);
markApiHistoryPrompt(input, 'real-prompt');
chat.setHistory([userText('review'), modelText('done'), input]);
expect(detectTurnInterruption(chat.getHistory())).toEqual({
kind: 'interrupted_prompt',
parts: input.parts,
});
});
});
describe('completed tool boundary', () => {
const result = content('user', fnResponse('update_goal', {}, 'ended'));
const fresh = () => structuredClone(result);
+12
View File
@@ -182,6 +182,10 @@ import {
cloneApiHistory,
} from '../services/session-api-history.js';
import { isAgentEnvelopeContent } from '../agents/session-agents/envelope.js';
import {
clearApiHistoryDirectoryContext,
isApiHistoryDirectoryContext,
} from '../utils/api-history-metadata.js';
export { InvalidStreamError };
@@ -1781,6 +1785,13 @@ function appendCuratedContent(
...lastContent,
parts: [...(lastContent.parts ?? []), ...(content.parts ?? [])],
};
if (
isApiHistoryDirectoryContext(lastContent) &&
!isApiHistoryDirectoryContext(content)
) {
// Object spread copies the Symbol; real input must not inherit it.
clearApiHistoryDirectoryContext(curatedHistory[lastIndex]!);
}
return;
}
@@ -6219,6 +6230,7 @@ export class LlmChat {
if (lastEntry && isAgentEnvelopeContent(lastEntry)) {
break;
}
// Keeping directory context here would duplicate it on Retry.
strippedEntries.unshift(this.history.pop()!);
}
// Today this is safe even without the reset — only trailing user
+11 -5
View File
@@ -10,6 +10,7 @@ import {
isSystemReminderContent,
} from './environmentContext.js';
import { isAgentEnvelopeContent } from '../agents/session-agents/envelope.js';
import { isApiHistoryDirectoryContext } from '../utils/api-history-metadata.js';
/**
* Classification of how a session's last turn ended, computed from persisted
@@ -256,12 +257,10 @@ export function effectiveHistoryEnd(
: history.length - trailingSystemNotifications;
while (end > 0) {
const entry = history[end - 1]!;
// A session multi-agent record (agent reply or @-mention) is durable
// conversation the main model was never asked to answer, so a trailing
// one is not an unfinished turn.
// Agent envelopes and marked directory refreshes do not ask for a reply.
// TODO(multi-agent): an agent entry after trailing notifications shifts
// the authoritative notification window below; revisit if that mixes.
if (isAgentEnvelopeContent(entry)) {
if (isAgentEnvelopeContent(entry) || isApiHistoryDirectoryContext(entry)) {
end--;
continue;
}
@@ -317,7 +316,8 @@ export function detectTurnInterruption(
// Walk from the REAL end, not the trimmed one, while the verdict above
// still reads `history[end - 1]`. The Retry send path
// (`stripOrphanedUserEntriesFromHistory`) pops the ENTIRE trailing user
// run — its only break-guard is `isSystemReminderContent`, which is false
// run, including marked directory context that must be replayed with input.
// Its structural break-guard is `isSystemReminderContent`, which is false
// for an envelope — so the re-submission has to carry every entry the
// strip removes. Collecting from `end - 1` instead would drop a
// recorded-but-undelivered notification from live history permanently:
@@ -337,6 +337,12 @@ export function detectTurnInterruption(
}
trailingUserEntries.unshift(entry);
}
if (
isSystemReminderContent(last) &&
trailingUserEntries.every(isApiHistoryDirectoryContext)
) {
return { kind: 'none' };
}
// Capture every part, including any per-turn system-reminder parts riding
// alongside the prompt. The Retry send path does not re-inject per-turn
// reminders, so replaying them keeps the continued turn complete. When a
@@ -70,7 +70,10 @@ import {
SessionWriterLease,
} from './session-writer-lease.js';
import { prepareTranscriptRecords } from '../utils/transcript-records.js';
import { getApiHistoryPromptId } from '../utils/api-history-metadata.js';
import {
getApiHistoryPromptId,
isApiHistoryDirectoryContext,
} from '../utils/api-history-metadata.js';
import { getStartupContextLength } from '../core/environmentContext.js';
import { computeInitialTurnFromHistory } from './session-turn-state.js';
import {
@@ -675,6 +678,8 @@ export interface ChatCompressionRecordPayload {
summaryPrefixLength?: number;
/** Prompt identities parallel to compressedHistory. */
promptIds?: Array<string | null>;
/** Internal directory context, without changing the model-facing snapshot. */
directoryContextIndices?: number[];
completedToolCallIds?: string[];
}
@@ -3262,6 +3267,10 @@ export class ChatRecordingService {
const promptIds = compressedHistory.map(
(content) => getApiHistoryPromptId(content) ?? null,
);
const directoryContextIndices = compressedHistory.flatMap(
(content, index) =>
isApiHistoryDirectoryContext(content) ? [index] : [],
);
const record: ChatRecord = {
...this.createBaseRecord('system'),
type: 'system',
@@ -3274,6 +3283,9 @@ export class ChatRecordingService {
}),
summaryPrefixLength,
...(promptIds.some(Boolean) ? { promptIds } : {}),
...(directoryContextIndices.length
? { directoryContextIndices }
: {}),
},
};
@@ -16,8 +16,10 @@ import type {
import { isInternalCodeModeToolResult } from '../utils/transcript-records.js';
import { markCompressedHistory } from '../core/environmentContext.js';
import {
clearApiHistoryDirectoryContext,
getApiHistoryPromptId,
markApiHistoryPrompt,
markApiHistoryDirectoryContext,
} from '../utils/api-history-metadata.js';
export {
@@ -221,6 +223,7 @@ function appendApiHistoryRecord(
)
) {
previous.parts = [...(previous.parts ?? []), ...(message.parts ?? [])];
clearApiHistoryDirectoryContext(previous);
return;
}
}
@@ -360,10 +363,18 @@ export class SessionApiHistoryAccumulator {
if (!isApiHistoryCompressionCandidate(record)) return;
const payload = record.systemPayload as ChatCompressionRecordPayload;
this.compressionCandidate = payload.compressedHistory;
const directoryContextIndices = new Set(
Array.isArray(payload.directoryContextIndices)
? payload.directoryContextIndices
: [],
);
this.history = Array.isArray(payload.compressedHistory)
? payload.compressedHistory.map((content, index) => {
const copy = copyContentForApiHistory(content);
markApiHistoryPrompt(copy, payload.promptIds?.[index]);
if (directoryContextIndices.has(index)) {
markApiHistoryDirectoryContext(copy);
}
return copy;
})
: [];
@@ -43,6 +43,10 @@ import { Storage } from '../config/storage.js';
import type { Config } from '../config/config.js';
import { CompressionStatus } from '../core/turn.js';
import { detectTurnInterruption } from '../core/turn-interruption.js';
import {
isApiHistoryDirectoryContext,
markApiHistoryDirectoryContext,
} from '../utils/api-history-metadata.js';
import {
SessionSourceService,
type SessionSourcesSnapshot,
@@ -675,6 +679,81 @@ describe('SessionTranscriptReader', () => {
},
);
it.each(['user', 'mid-turn'] as const)(
'keeps directory context non-interrupting after saving and reloading a checkpoint (%s)',
async (inputKind) => {
const config = {
storage: new Storage(workspaceDir),
getSessionId: () => sessionId,
getProjectRoot: () => workspaceDir,
getCliVersion: () => 'test',
getModel: () => 'test',
getAuthType: () => undefined,
getApprovalMode: () => ApprovalMode.DEFAULT,
getResumedSessionData: () => undefined,
} as unknown as Config;
const recorder = new ChatRecordingService(config, undefined, false);
const history = [
userText('review'),
modelText('done'),
markApiHistoryDirectoryContext(userText('Directory changed.')),
markApiHistoryDirectoryContext(userText('Directory structure.')),
];
const wireHistory = JSON.parse(JSON.stringify(history));
recorder.recordChatCompression({
info: {
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount: 100,
newTokenCount: 50,
},
compressedHistory: history,
});
await recorder.flush();
const service = svc();
const loaded = await service.loadSession(sessionId);
const checkpoint = loaded!.conversation.messages.find(
(entry) => entry.subtype === 'chat_compression',
);
expect(checkpoint?.systemPayload).toMatchObject({
compressedHistory: wireHistory,
directoryContextIndices: [2, 3],
});
const projection = await service.readRestoreProjection(sessionId, NONE);
const restored = buildSessionHistoryFromConversation(
loaded!.conversation,
);
for (const apiHistory of [
restored.apiHistory,
projection!.runtime.apiHistory,
]) {
expect(JSON.stringify(apiHistory)).toBe(JSON.stringify(wireHistory));
expect(apiHistory.map(isApiHistoryDirectoryContext)).toEqual([
false,
false,
true,
true,
]);
expect(detectTurnInterruption(apiHistory).kind).toBe('none');
}
if (inputKind === 'user') {
recorder.recordUserMessage([{ text: 'Unanswered real input' }]);
} else {
recorder.recordMidTurnUserMessage(
[{ text: 'Unanswered real input' }],
'Unanswered real input',
);
}
await recorder.flush();
const next = await service.readRestoreProjection(sessionId, NONE);
expect(detectTurnInterruption(next!.runtime.apiHistory)).toMatchObject({
kind: 'interrupted_prompt',
parts: expect.arrayContaining([{ text: 'Unanswered real input' }]),
});
},
);
it('round-trips a recorded Goal turn end through selective restore, fork, and rewind', async () => {
const config = {
storage: new Storage(workspaceDir),
@@ -7,11 +7,31 @@
import type { Content } from '@google/genai';
const API_HISTORY_PROMPT_ID = Symbol('apiHistoryPromptId');
const DIRECTORY_CONTEXT = Symbol('directoryContext');
type IdentifiedContent = Content & {
[API_HISTORY_PROMPT_ID]?: string;
[DIRECTORY_CONTEXT]?: boolean;
};
export function markApiHistoryDirectoryContext(content: Content): Content {
(content as IdentifiedContent)[DIRECTORY_CONTEXT] = true;
return content;
}
export function clearApiHistoryDirectoryContext(content: Content): void {
delete (content as IdentifiedContent)[DIRECTORY_CONTEXT];
}
export function isApiHistoryDirectoryContext(content: Content): boolean {
return (
content.role === 'user' &&
(content as IdentifiedContent)[DIRECTORY_CONTEXT] === true &&
content.parts?.every((part) => typeof part.text === 'string') === true &&
getApiHistoryPromptId(content) === undefined
);
}
export function cloneApiHistory(history: Content[]): Content[] {
// structuredClone drops the non-serialized prompt and context boundaries.
return history.map((content) => ({
@@ -10,6 +10,7 @@ import type { Content } from '@google/genai';
import type { ChatRecord } from '../services/chatRecordingService.js';
import { SessionFileHistoryAccumulator } from '../services/session-file-history-state.js';
import { CompressionStatus } from '../core/turn.js';
import { detectTurnInterruption } from '../core/turn-interruption.js';
import { buildApiHistoryFromConversation } from '../services/session-api-history.js';
import { prepareTranscriptRecords } from './transcript-records.js';
import {
@@ -122,6 +123,65 @@ describe('message group deletion', () => {
expect(getTranscriptMessageGroups(kept)).toEqual([]);
});
it('remaps directory context identities across a deleted checkpoint turn', () => {
const u1 = user('u1', null);
const a1 = record('a1', 'u1', 'assistant');
const u2 = user('u2', 'a1');
const a2 = record('a2', 'u2', 'assistant');
const u3 = user('u3', 'a2');
const a3 = record('a3', 'u3', 'assistant');
const firstContext: Content = {
role: 'user',
parts: [{ text: 'Directory changed.' }],
};
const lastContext: Content = {
role: 'user',
parts: [{ text: 'Directory structure.' }],
};
const compression = record('compressed', 'a3', 'system', {
subtype: 'chat_compression',
systemPayload: {
info: {
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount: 100,
newTokenCount: 50,
},
compressedHistory: [
u1.message!,
a1.message!,
firstContext,
u2.message!,
a2.message!,
u3.message!,
a3.message!,
lastContext,
],
promptIds: ['api-u1', null, null, 'api-u2', null, 'api-u3', null, null],
directoryContextIndices: [2, 7],
},
});
const { records: kept } = deleteTranscriptMessageGroups(
[u1, a1, u2, a2, u3, a3, compression],
['u2'],
anchor,
);
expect(
kept.find((entry) => entry.uuid === 'compressed')?.systemPayload,
).toMatchObject({ directoryContextIndices: [2, 5] });
const restored = buildApiHistoryFromConversation({ messages: kept });
expect(JSON.stringify(restored)).toBe(
JSON.stringify([
u1.message,
a1.message,
firstContext,
u3.message,
a3.message,
lastContext,
]),
);
expect(detectTurnInterruption(restored).kind).toBe('none');
});
it('preserves the summary and prunes raw copies and tools from every compression checkpoint', () => {
const summary: Content = {
role: 'user',
@@ -403,6 +403,15 @@ function pruneCompression(
(index) => payload.compressedHistory[index]!,
),
promptIds: retained.map((index) => payload.promptIds![index]!),
...(Array.isArray(payload.directoryContextIndices)
? {
directoryContextIndices: retained.flatMap((index, newIndex) =>
payload.directoryContextIndices!.includes(index)
? [newIndex]
: [],
),
}
: {}),
},
};
}