Skip to content
Merged
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
21 changes: 16 additions & 5 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6256,9 +6256,11 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
let lastSessionMock:
| {
getId: ReturnType<typeof vi.fn>;
getConfig: ReturnType<typeof vi.fn>;
sendAvailableCommandsUpdate: ReturnType<typeof vi.fn>;
replayHistory: ReturnType<typeof vi.fn>;
installRewriter: ReturnType<typeof vi.fn>;
startCronScheduler: ReturnType<typeof vi.fn>;
dispose: ReturnType<typeof vi.fn>;
}
| undefined;
Expand Down Expand Up @@ -6320,6 +6322,9 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
resumedConversation?: { messages: unknown[] };
} = {},
) {
const recording = {
rebuildTurnBoundaries: vi.fn(),
};
return {
initialize: vi.fn().mockResolvedValue(undefined),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
Expand All @@ -6345,6 +6350,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
getHookSystem: vi.fn().mockReturnValue(undefined),
getDisableAllHooks: vi.fn().mockReturnValue(true),
hasHooksForEvent: vi.fn().mockReturnValue(false),
getChatRecordingService: vi.fn().mockReturnValue(recording),
// load path reads back the persisted conversation here and feeds
// it to `session.replayHistory`. resume path doesn't read this.
getResumedSessionData: vi
Expand Down Expand Up @@ -6433,10 +6439,11 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
});

it('loadSession returns LoadSessionResponse and replays history on the session', async () => {
const messages = [{ role: 'user', parts: [{ text: 'hi' }] }];
bindRestoreMocks({
sessionExists: true,
resumedConversation: {
messages: [{ role: 'user', parts: [{ text: 'hi' }] }],
messages,
},
});
const { agent, agentPromise } = await spawnAgent();
Expand All @@ -6454,9 +6461,10 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
});
// load semantic: history MUST be replayed so SSE subscribers see
// the persisted turns.
expect(lastSessionMock?.replayHistory).toHaveBeenCalledWith([
{ role: 'user', parts: [{ text: 'hi' }] },
]);
expect(lastSessionMock?.replayHistory).toHaveBeenCalledWith(messages);

const recording = lastSessionMock?.getConfig().getChatRecordingService();
expect(recording?.rebuildTurnBoundaries).toHaveBeenCalledWith(messages);

mockConnectionState.resolve();
await agentPromise;
Expand Down Expand Up @@ -6539,10 +6547,11 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
});

it('unstable_resumeSession returns the response without replaying history', async () => {
const messages = [{ role: 'user', parts: [{ text: 'hi' }] }];
bindRestoreMocks({
sessionExists: true,
resumedConversation: {
messages: [{ role: 'user', parts: [{ text: 'hi' }] }],
messages,
},
});
const { agent, agentPromise } = await spawnAgent();
Expand All @@ -6562,6 +6571,8 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
// the SSE stream stays clean for clients that already have the
// history rendered.
expect(lastSessionMock?.replayHistory).not.toHaveBeenCalled();
const recording = lastSessionMock?.getConfig().getChatRecordingService();
expect(recording?.rebuildTurnBoundaries).toHaveBeenCalledWith(messages);

mockConnectionState.resolve();
await agentPromise;
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7318,6 +7318,12 @@ class QwenAgent implements Agent {
.restoreFromSnapshots(sessionData.fileHistorySnapshots);
}

if (sessionData?.conversation.messages) {
config
.getChatRecordingService()
?.rebuildTurnBoundaries(sessionData.conversation.messages);
}

if (options.replayHistory !== false && sessionData?.conversation.messages) {
await session.replayHistory(sessionData.conversation.messages);
}
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/services/chatRecordingService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,47 @@ describe('ChatRecordingService', () => {
});
});

describe('rewindRecording', () => {
it('preserves a resumed user turn parent when rebuilding rewind boundaries', async () => {
vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({
lastCompletedUuid: 'assistant-1',
} as unknown as ReturnType<Config['getResumedSessionData']>);
chatRecordingService = new ChatRecordingService(mockConfig);

chatRecordingService.rebuildTurnBoundaries([
{
uuid: 'user-1',
parentUuid: 'pre-resume-parent',
sessionId: 'test-session-id',
timestamp: '2026-06-27T00:00:00.000Z',
type: 'user',
cwd: '/test/project/root',
version: '1.0.0',
message: { role: 'user', parts: [{ text: 'first resumed turn' }] },
},
{
uuid: 'assistant-1',
parentUuid: 'user-1',
sessionId: 'test-session-id',
timestamp: '2026-06-27T00:00:01.000Z',
type: 'assistant',
cwd: '/test/project/root',
version: '1.0.0',
message: { role: 'model', parts: [{ text: 'response' }] },
model: 'gemini-pro',
},
]);

chatRecordingService.rewindRecording(0, { truncatedCount: 2 });
await chatRecordingService.flush();

expect(jsonl.writeLine).toHaveBeenCalledTimes(1);
const rewind = vi.mocked(jsonl.writeLine).mock.calls[0][1] as ChatRecord;
expect(rewind.subtype).toBe('rewind');
expect(rewind.parentUuid).toBe('pre-resume-parent');
});
});

describe('recordAtCommand', () => {
it('should record @-command metadata as a system payload', async () => {
const userParts: Part[] = [{ text: 'Hello, world!' }];
Expand Down
9 changes: 3 additions & 6 deletions packages/core/src/services/chatRecordingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1230,10 +1230,6 @@ export class ChatRecordingService {
*/
rebuildTurnBoundaries(messages: ChatRecord[]): void {
this.turnParentUuids = [];
let prevUuid: string | null =
this.config.getResumedSessionData()?.lastCompletedUuid !== undefined
? null
: this.lastRecordUuid;

for (let i = 0; i < messages.length; i++) {
const record = messages[i];
Expand All @@ -1243,9 +1239,10 @@ export class ChatRecordingService {
record.subtype !== 'cron' &&
record.subtype !== 'mid_turn_user_message'
) {
this.turnParentUuids.push(prevUuid);
// Reconstructed histories can start mid-chain; the persisted edge is
// the source of truth, not the previous item in this sliced list.
this.turnParentUuids.push(record.parentUuid ?? null);
}
prevUuid = record.uuid;
}
// Ensure lastRecordUuid points to the end of the reconstructed chain.
if (messages.length > 0) {
Expand Down
Loading