Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
35 changes: 34 additions & 1 deletion packages/cli/src/llm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { clearCiEnv } from './test-utils/ci-env.js';
import type { CliArgs } from './config/config.js';
import { type LoadedSettings } from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
import type { Config } from '@qwen-code/qwen-code-core';
import type { ChatRecord, Config } from '@qwen-code/qwen-code-core';
import { ApprovalMode, OutputFormat } from '@qwen-code/qwen-code-core';
import { EXTERNAL_TOOL_GUARD_REQUIRED_VALUE } from '@qwen-code/acp-bridge/externalToolGuard';

Expand Down Expand Up @@ -1736,6 +1736,39 @@ describe('llm.tsx main function', () => {
);
});

it('continues the prompt id chain when the -p run resumes a session', () => {
// Every headless `-p` process would otherwise mint `########0` again, and
// loadSession keeps only the last file-history snapshot per promptId, so
// the earlier run's /rewind target for turn 0 would be dropped.
const records = [
{
uuid: 'u1',
parentUuid: null,
sessionId: 'test-session-id',
timestamp: new Date().toISOString(),
type: 'user',
cwd: '/tmp',
version: 'test',
message: { role: 'user', parts: [{ text: 'first turn' }] },
},
{
uuid: 'u2',
parentUuid: 'u1',
sessionId: 'test-session-id',
timestamp: new Date().toISOString(),
type: 'system',
subtype: 'ui_telemetry',
cwd: '/tmp',
version: 'test',
systemPayload: { uiEvent: { prompt_id: 'test-session-id########3' } },
},
] as unknown as ChatRecord[];

expect(createNonInteractivePromptId('test-session-id', records)).toBe(
'test-session-id########4',
);
});

const runSandboxRelaunch = async (
argv: string[],
sessionId = '123e4567-e89b-12d3-a456-426614174000',
Expand Down
27 changes: 24 additions & 3 deletions packages/cli/src/llm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import {
AuthType,
type ChatRecord,
computeInitialTurnFromHistory,
type Config,
InputFormat,
isDebugLogFileEnabled,
Expand Down Expand Up @@ -1419,7 +1421,10 @@ export async function main() {
settings,
);

const prompt_id = createNonInteractivePromptId(config.getSessionId());
const prompt_id = createNonInteractivePromptId(
config.getSessionId(),
config.getResumedSessionData?.()?.conversation.messages,
);

if (inputFormat === InputFormat.STREAM_JSON) {
const trimmedInput = (input ?? '').trim();
Expand Down Expand Up @@ -1493,8 +1498,24 @@ export async function main() {
}
}

export function createNonInteractivePromptId(sessionId: string): string {
return `${sessionId}########0`;
/**
* Mints the single promptId a headless `-p` run uses for its one turn.
*
* A fresh session keeps the historical `########0`. A resumed one
* (`--resume` / `--continue`) must continue past the turns the transcript
* already claims: every process would otherwise mint `########0` again, and
* `SessionService.loadSession` keeps only the LAST file-history snapshot per
* promptId, so a repeated headless run silently drops the previous run's
* `/rewind` target for that turn.
*/
export function createNonInteractivePromptId(
sessionId: string,
resumedRecords?: readonly ChatRecord[],
): string {
const lastTurn = resumedRecords?.length
? computeInitialTurnFromHistory(resumedRecords, sessionId)
: 0;
return `${sessionId}########${lastTurn > 0 ? lastTurn + 1 : 0}`;
}

/**
Expand Down
101 changes: 100 additions & 1 deletion packages/cli/src/nonInteractive/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SendMessageType, type Config } from '@qwen-code/qwen-code-core';
import {
SendMessageType,
type ChatRecord,
type Config,
} from '@qwen-code/qwen-code-core';
import type { Content } from '@google/genai';
import { runNonInteractiveStreamJson } from './session.js';
import type {
Expand Down Expand Up @@ -106,6 +110,33 @@ function createUserMessage(content: string): CLIUserMessage {
};
}

function createResumedUserRecord(text: string): ChatRecord {
return {
uuid: `uuid-${text}`,
parentUuid: null,
sessionId: 'test-session',
timestamp: new Date().toISOString(),
type: 'user',
cwd: '/tmp',
version: 'test',
message: { role: 'user', parts: [{ text }] },
} as ChatRecord;
}

function createResumedTelemetryRecord(promptId: string): ChatRecord {
return {
uuid: `uuid-${promptId}`,
parentUuid: null,
sessionId: 'test-session',
timestamp: new Date().toISOString(),
type: 'system',
subtype: 'ui_telemetry',
cwd: '/tmp',
version: 'test',
systemPayload: { uiEvent: { prompt_id: promptId } },
} as unknown as ChatRecord;
}

function createControlRequest(
subtype: 'initialize' | 'set_model' | 'interrupt' = 'initialize',
): CLIControlRequest {
Expand Down Expand Up @@ -386,6 +417,74 @@ describe('runNonInteractiveStreamJson', () => {
);
});

it('mints a fresh promptId chain when nothing was resumed', async () => {
mockInputReader.read = async function* () {
yield createUserMessage('Hello world');
};

await runNonInteractiveStreamJson(config, '');

expect(runNonInteractiveMock.mock.calls[0][3]).toBe(
'test-session########1',
);
});

it('seeds the promptId counter past turns the resumed transcript claims', async () => {
// A resumed headless chain reuses the session id, so restarting the
// counter at 1 would re-mint ids the previous run already persisted —
// loadSession keeps only the last file-history snapshot per promptId,
// silently dropping the earlier run's /rewind target for that turn.
config = createConfig({
getResumedSessionData: () => ({
conversation: {
sessionId: 'test-session',
messages: [
createResumedUserRecord('first turn'),
createResumedUserRecord('second turn'),
createResumedTelemetryRecord('test-session########5'),
],
},
}),
});

mockInputReader.read = async function* () {
yield createUserMessage('third turn');
};

await runNonInteractiveStreamJson(config, '');

// 5 is the highest turn the transcript claims (a ui_telemetry record from
// the previous run), not the 2 user turns it happens to contain.
expect(runNonInteractiveMock.mock.calls[0][3]).toBe(
'test-session########6',
);
});

it('falls back to the resumed user-turn count when no promptId is persisted', async () => {
config = createConfig({
getResumedSessionData: () => ({
conversation: {
sessionId: 'test-session',
messages: [
createResumedUserRecord('first turn'),
createResumedUserRecord('second turn'),
createResumedUserRecord('third turn'),
],
},
}),
});

mockInputReader.read = async function* () {
yield createUserMessage('fourth turn');
};

await runNonInteractiveStreamJson(config, '');

expect(runNonInteractiveMock.mock.calls[0][3]).toBe(
'test-session########4',
);
});

it('processes multiple user messages sequentially', async () => {
// Initialize first to enable multi-query mode
const initRequest = createControlRequest('initialize');
Expand Down
27 changes: 26 additions & 1 deletion packages/cli/src/nonInteractive/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
import { SendMessageType } from '@qwen-code/qwen-code-core/core/client.js';
import { buildSessionRecoveryPlanFromApiHistory } from '@qwen-code/qwen-code-core/core/session-recovery.js';
import { TURN_INTERRUPTION_HISTORY_TAIL_COUNT } from '@qwen-code/qwen-code-core/core/turn-interruption.js';
import { computeInitialTurnFromHistory } from '@qwen-code/qwen-code-core/services/session-turn-state.js';
import { createDebugLogger } from '@qwen-code/qwen-code-core/utils/debugLogger.js';
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
Expand Down Expand Up @@ -77,7 +78,7 @@ class Session {
private activeTurnAbortController: AbortController | null = null;
private config: Config;
private sessionId: string;
private promptIdCounter: number = 0;
private promptIdCounter: number | null = null;
private inputReader: StreamJsonInputReader;
private outputAdapter: StreamJsonOutputAdapter;
private controlContext: ControlContext | null = null;
Expand Down Expand Up @@ -142,7 +143,31 @@ class Session {
});
}

/**
* Mints the next promptId for this process.
*
* The counter is seeded from the resumed transcript on first use. Without
* that seed a `--resume`/`--continue` chain restarts at 1 every process and
* re-mints promptIds the previous run already persisted, which is not just a
* cosmetic collision: `SessionService.loadSession` keeps only the LAST
* file-history snapshot per promptId, so the earlier run's snapshot for that
* turn is dropped and `/rewind` restores the wrong workspace state. Rewind's
* prompt-identity mapping also fails closed to a positional walk when ids
* repeat. Interactive mode seeds the same way (`seedPromptCount` in
* AppContainer), and ACP via `computeInitialTurnFromHistory`.
*
* Seeding is lazy because resumed data only becomes authoritative after
* `config.initialize()` re-reads the session file, which this class defers
* until the first control request.
*/
private getNextPromptId(): string {
if (this.promptIdCounter === null) {
const records =
this.config.getResumedSessionData?.()?.conversation.messages;
this.promptIdCounter = records
? computeInitialTurnFromHistory(records, this.sessionId)
: 0;
}
this.promptIdCounter++;
return `${this.sessionId}########${this.promptIdCounter}`;
}
Expand Down
Loading