mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(core): avoid false recovery after directory changes (#15012)
Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
1 parent
9a913c7302
commit
257b9f5413
11 files changed
+435
-10
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
: [],
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user