diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts
index 3a77468a145..5bffc282686 100644
--- a/packages/core/src/core/client.test.ts
+++ b/packages/core/src/core/client.test.ts
@@ -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: `\n${gitStatus}\n`,
+ },
+ ]
+ : []),
+ ],
+ },
+ 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(
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index fdba76e581f..5155ced8a59 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -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();
}
diff --git a/packages/core/src/core/llm-chat.test.ts b/packages/core/src/core/llm-chat.test.ts
index 92a3bd59196..0de695fec0e 100644
--- a/packages/core/src/core/llm-chat.test.ts
+++ b/packages/core/src/core/llm-chat.test.ts
@@ -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('\nNew MCP tools.\n'),
+ ...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('\nNew MCP tools.\n'),
+ ];
+ 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);
diff --git a/packages/core/src/core/llm-chat.ts b/packages/core/src/core/llm-chat.ts
index 0ce0a95fa05..e1f28fa38a8 100644
--- a/packages/core/src/core/llm-chat.ts
+++ b/packages/core/src/core/llm-chat.ts
@@ -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
diff --git a/packages/core/src/core/turn-interruption.ts b/packages/core/src/core/turn-interruption.ts
index ef5f8fb376b..314ac9aad65 100644
--- a/packages/core/src/core/turn-interruption.ts
+++ b/packages/core/src/core/turn-interruption.ts
@@ -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
diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts
index 45ad51a434e..50d1c0ce485 100644
--- a/packages/core/src/services/chatRecordingService.ts
+++ b/packages/core/src/services/chatRecordingService.ts
@@ -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;
+ /** 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 }
+ : {}),
},
};
diff --git a/packages/core/src/services/session-api-history.ts b/packages/core/src/services/session-api-history.ts
index d20c8d654df..0aa2c83cd5a 100644
--- a/packages/core/src/services/session-api-history.ts
+++ b/packages/core/src/services/session-api-history.ts
@@ -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;
})
: [];
diff --git a/packages/core/src/services/session-transcript-reader.test.ts b/packages/core/src/services/session-transcript-reader.test.ts
index 22202b69dc8..5e07d2e2652 100644
--- a/packages/core/src/services/session-transcript-reader.test.ts
+++ b/packages/core/src/services/session-transcript-reader.test.ts
@@ -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),
diff --git a/packages/core/src/utils/api-history-metadata.ts b/packages/core/src/utils/api-history-metadata.ts
index d6b605507ec..95cfc772ebf 100644
--- a/packages/core/src/utils/api-history-metadata.ts
+++ b/packages/core/src/utils/api-history-metadata.ts
@@ -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) => ({
diff --git a/packages/core/src/utils/message-group-deletion.test.ts b/packages/core/src/utils/message-group-deletion.test.ts
index cfc3a78b296..8032a43edbd 100644
--- a/packages/core/src/utils/message-group-deletion.test.ts
+++ b/packages/core/src/utils/message-group-deletion.test.ts
@@ -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',
diff --git a/packages/core/src/utils/message-group-deletion.ts b/packages/core/src/utils/message-group-deletion.ts
index 7d48df9576f..df4e8d304b9 100644
--- a/packages/core/src/utils/message-group-deletion.ts
+++ b/packages/core/src/utils/message-group-deletion.ts
@@ -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]
+ : [],
+ ),
+ }
+ : {}),
},
};
}