From a5e4839e07ea155323f56f2f2d97fc89232adeda Mon Sep 17 00:00:00 2001 From: kkhomej33-netizen Date: Sun, 17 May 2026 18:32:44 +0800 Subject: [PATCH] fix(cli): restore ACP prompt counter on resume (#4233) --- .../acp-integration/session/Session.test.ts | 137 +++++++++++++++++- .../src/acp-integration/session/Session.ts | 70 +++++++++ 2 files changed, 205 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index e11f4ac43..7127cbf03 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -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): 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 diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index de68a1013..db408553c 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -121,6 +121,72 @@ type AutoCompressionSendResult = | { responseStream: AsyncGenerator; 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 { + this.turn = Math.max( + this.turn, + computeInitialTurnFromHistory(records, this.config.getSessionId()), + ); await this.historyReplayer.replay(records); }