fix(cli): restore ACP prompt counter on resume (#4233)

This commit is contained in:
kkhomej33-netizen 2026-05-17 18:32:44 +08:00 committed by GitHub
parent 0240c310fd
commit a5e4839e07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 205 additions and 2 deletions

View file

@ -8,9 +8,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { Session } from './Session.js';
import { computeInitialTurnFromHistory, Session } from './Session.js';
import type { Content } from '@google/genai';
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
import type { ChatRecord, Config, GeminiChat } from '@qwen-code/qwen-code-core';
import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core';
import * as core from '@qwen-code/qwen-code-core';
import { SettingScope } from '../../config/settings.js';
@ -33,6 +33,96 @@ vi.mock('../../nonInteractiveCliCommands.js', () => ({
handleSlashCommand: vi.fn(),
}));
function chatRecord(overrides: Record<string, unknown>): ChatRecord {
return {
uuid: 'record',
parentUuid: null,
sessionId: 'test-session-id',
timestamp: '2026-05-17T07:27:15.251Z',
type: 'user',
cwd: process.cwd(),
version: '0.15.11',
...overrides,
} as ChatRecord;
}
describe('computeInitialTurnFromHistory', () => {
it('uses the largest numeric prompt id suffix for the current session', () => {
expect(
computeInitialTurnFromHistory(
[
chatRecord({
uuid: 'user-1',
promptId: 'test-session-id########1',
message: { parts: [{ text: '1' }] },
}),
chatRecord({
uuid: 'system-1',
timestamp: '2026-05-17T07:27:23.470Z',
type: 'system',
subtype: 'ui_telemetry',
systemPayload: {
uiEvent: {
prompt_id: 'test-session-id########2',
},
},
}),
chatRecord({
uuid: 'system-notification',
timestamp: '2026-05-17T07:27:24.000Z',
type: 'system',
subtype: 'ui_telemetry',
systemPayload: {
uiEvent: {
prompt_id: 'test-session-id########notification123',
},
},
}),
chatRecord({
uuid: 'other-session',
sessionId: 'other-session-id',
timestamp: '2026-05-17T07:27:25.000Z',
promptId: 'other-session-id########99',
message: { parts: [{ text: 'other' }] },
}),
],
'test-session-id',
),
).toBe(2);
});
it('falls back to user message count when prompt ids are absent', () => {
expect(
computeInitialTurnFromHistory(
[
chatRecord({
uuid: 'user-1',
message: { parts: [{ text: '1' }] },
}),
chatRecord({
uuid: 'assistant-1',
timestamp: '2026-05-17T07:27:18.861Z',
type: 'assistant',
message: { parts: [{ text: 'answer 1' }] },
}),
chatRecord({
uuid: 'user-2',
timestamp: '2026-05-17T07:27:20.446Z',
message: { parts: [{ text: '2' }] },
}),
chatRecord({
uuid: 'other-session',
sessionId: 'other-session-id',
timestamp: '2026-05-17T07:27:25.000Z',
message: { parts: [{ text: 'other' }] },
}),
],
'test-session-id',
),
).toBe(2);
});
});
// Helper to create empty async generator (avoids memory leak from inline generators)
function createEmptyStream() {
return (async function* () {})();
@ -619,6 +709,49 @@ describe('Session', () => {
});
describe('prompt', () => {
it('continues ACP prompt ids after replaying resumed history', async () => {
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());
await session.replayHistory([
chatRecord({
uuid: 'user-1',
promptId: 'test-session-id########1',
message: { parts: [{ text: '1' }] },
}),
chatRecord({
uuid: 'assistant-1',
timestamp: '2026-05-17T07:27:18.861Z',
type: 'assistant',
promptId: 'test-session-id########1',
message: { parts: [{ text: 'answer 1' }] },
}),
chatRecord({
uuid: 'user-2',
timestamp: '2026-05-17T07:27:20.446Z',
promptId: 'test-session-id########2',
message: { parts: [{ text: '2' }] },
}),
]);
await expect(
session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: '3' }],
}),
).resolves.toEqual({ stopReason: 'end_turn' });
expect(mockChatRecordingService.recordUserMessage).toHaveBeenCalledWith(
'3',
);
expect(mockGeminiClient.tryCompressChat).toHaveBeenCalledWith(
'test-session-id########3',
false,
expect.any(AbortSignal),
);
});
describe('auto-compress', () => {
it('runs automatic compression before sending an ACP prompt', async () => {
mockChat.sendMessageStream = vi

View file

@ -121,6 +121,72 @@ type AutoCompressionSendResult =
| { responseStream: AsyncGenerator<StreamEvent>; stopReason?: never }
| { responseStream: null; stopReason: PromptResponse['stopReason'] };
export function computeInitialTurnFromHistory(
records: ChatRecord[],
sessionId: string,
): number {
let maxPromptTurn = 0;
let userMessageCount = 0;
const promptIdPrefix = `${sessionId}########`;
for (const record of records) {
if (record.sessionId === sessionId && isUserPromptRecord(record)) {
userMessageCount += 1;
}
for (const promptId of getRecordPromptIds(record)) {
if (!promptId.startsWith(promptIdPrefix)) {
continue;
}
const suffix = promptId.slice(promptIdPrefix.length);
if (!/^\d+$/.test(suffix)) {
continue;
}
maxPromptTurn = Math.max(maxPromptTurn, Number(suffix));
}
}
return maxPromptTurn > 0 ? maxPromptTurn : userMessageCount;
}
function getRecordPromptIds(record: ChatRecord): string[] {
const promptIds: string[] = [];
const recordPromptId = (record as { promptId?: unknown }).promptId;
if (typeof recordPromptId === 'string') {
promptIds.push(recordPromptId);
}
const telemetryPromptId = readTelemetryPromptId(record.systemPayload);
if (telemetryPromptId) {
promptIds.push(telemetryPromptId);
}
return promptIds;
}
function readTelemetryPromptId(payload: unknown): string | undefined {
if (!payload || typeof payload !== 'object' || !('uiEvent' in payload)) {
return undefined;
}
const uiEvent = (payload as { uiEvent?: unknown }).uiEvent;
if (!uiEvent || typeof uiEvent !== 'object' || !('prompt_id' in uiEvent)) {
return undefined;
}
const promptId = (uiEvent as { prompt_id?: unknown }).prompt_id;
return typeof promptId === 'string' ? promptId : undefined;
}
function isUserPromptRecord(record: ChatRecord): boolean {
if (record.type !== 'user') {
return false;
}
return (
record.message?.parts?.some(
(part) => typeof part.text === 'string' && part.text.trim().length > 0,
) ?? false
);
}
/**
* Session represents an active conversation session with the AI model.
* It uses modular components for consistent event emission:
@ -208,6 +274,10 @@ export class Session implements SessionContext {
* Delegates to HistoryReplayer for consistent event emission.
*/
async replayHistory(records: ChatRecord[]): Promise<void> {
this.turn = Math.max(
this.turn,
computeInitialTurnFromHistory(records, this.config.getSessionId()),
);
await this.historyReplayer.replay(records);
}