mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-19 16:28:28 +00:00
fix(cli): restore ACP prompt counter on resume (#4233)
This commit is contained in:
parent
0240c310fd
commit
a5e4839e07
2 changed files with 205 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue