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
6 changes: 5 additions & 1 deletion packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4611,7 +4611,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
withTimeout(
entry.connection.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionRewind,
{ sessionId, promptId: req.promptId, rewindFiles: true },
{
sessionId,
promptId: req.promptId,
rewindFiles: req.rewindFiles !== false,
},
),
initTimeoutMs,
SERVE_CONTROL_EXT_METHODS.sessionRewind,
Expand Down
1 change: 1 addition & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface RewindSnapshotInfo {

export interface RewindRequest {
promptId: string;
rewindFiles?: boolean;
}

export interface RewindResponse {
Expand Down
39 changes: 38 additions & 1 deletion packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
emitGoalStatus: ReturnType<typeof vi.fn>;
restoreHistory: ReturnType<typeof vi.fn>;
rewindToTurn: ReturnType<typeof vi.fn>;
getRewindableUserTurnCount: ReturnType<typeof vi.fn>;
}
| undefined;
let processExitSpy: MockInstance<typeof process.exit>;
Expand Down Expand Up @@ -1371,6 +1372,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
rewindToTurn: vi
.fn()
.mockReturnValue({ targetTurnIndex: 1, apiTruncateIndex: 2 }),
getRewindableUserTurnCount: vi.fn().mockReturnValue(1),
};
lastSessionMock = sessionMock;
return sessionMock as unknown as InstanceType<typeof Session>;
Expand Down Expand Up @@ -4779,7 +4781,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
cwd: '/tmp',
});

expect(lastSessionMock?.rewindToTurn).toHaveBeenCalledWith(1);
expect(lastSessionMock?.rewindToTurn).toHaveBeenCalledWith(1, {
rewindFiles: true,
});
expect(response).toEqual({
success: true,
historyBeforeRewind: [{ role: 'user', parts: [{ text: 'before' }] }],
Expand All @@ -4793,6 +4797,39 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});

it('rewindSession extension method can skip file rewind', async () => {
const sessionId = '11111111-1111-1111-1111-111111111111';
await setupSessionMocks(sessionId);

const agentPromise = runAcpAgent(
mockConfig,
makeSessionSettings(),
mockArgv,
);
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());

const agent = capturedAgentFactory!({
get closed() {
return mockConnectionState.promise;
},
}) as AgentLike;

await agent.newSession({ cwd: '/tmp', mcpServers: [] });
await agent.extMethod('rewindSession', {
sessionId,
targetTurnIndex: 1,
rewindFiles: false,
cwd: '/tmp',
});

expect(lastSessionMock?.rewindToTurn).toHaveBeenCalledWith(1, {
rewindFiles: false,
});

mockConnectionState.resolve();
await agentPromise;
});

it('rewindSession rejects invalid session ids', async () => {
await setupSessionMocks('11111111-1111-1111-1111-111111111111');

Expand Down
34 changes: 23 additions & 11 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ import type {
AgentParams,
ApprovalMode,
Config,
ConversationRecord,
DeviceAuthorizationData,
HookConfig,
McpBudgetEvent,
Expand All @@ -75,6 +74,7 @@ import type {
ProviderConfig,
ProviderModelConfig,
ProviderSetupInputs,
ResumedSessionData,
} from '@qwen-code/qwen-code-core';
import {
AgentSideConnection,
Expand Down Expand Up @@ -2823,10 +2823,7 @@ class QwenAgent implements Agent {
this.setupFileSystem(config);

const sessionData = config.getResumedSessionData();
const session = await this.createAndStoreSession(
config,
sessionData?.conversation,
);
const session = await this.createAndStoreSession(config, sessionData);

await this.#restoreWorktreeOnResume(config, session);

Expand Down Expand Up @@ -2865,7 +2862,11 @@ class QwenAgent implements Agent {
await this.ensureAuthenticated(config);
this.setupFileSystem(config);

const session = await this.createAndStoreSession(config);
const session = await this.createAndStoreSession(
config,
config.getResumedSessionData(),
{ replayHistory: false },
);

await this.#restoreWorktreeOnResume(config, session);

Expand Down Expand Up @@ -5138,6 +5139,7 @@ class QwenAgent implements Agent {
}
const fhs = session.getConfig().getFileHistoryService();
const snapshots = fhs.getSnapshots();
const rewindableTurnCount = session.getRewindableUserTurnCount();
const prefix = (sessionId as string) + '########';
const results = await Promise.all(
snapshots
Expand All @@ -5147,6 +5149,7 @@ class QwenAgent implements Agent {
s.promptId.startsWith(prefix) &&
/^\d+$/.test(s.promptId.slice(prefix.length)),
)
.filter(({ idx }) => idx < rewindableTurnCount)
.map(async ({ s, idx }) => {
const stats = await fhs.getDiffStats(s.promptId);
return {
Expand Down Expand Up @@ -6324,10 +6327,13 @@ class QwenAgent implements Agent {
);
}

const rewindFiles = params['rewindFiles'] !== false;
const historyBeforeRewind = session.captureHistorySnapshot();
let rewindResult;
try {
rewindResult = session.rewindToTurn(turnIndex as number);
rewindResult = session.rewindToTurn(turnIndex as number, {
rewindFiles,
});
} catch (err) {
if (err instanceof RequestError) {
const msg = err.message;
Expand All @@ -6347,7 +6353,6 @@ class QwenAgent implements Agent {

let filesChanged: string[] = [];
let filesFailed: string[] = [];
const rewindFiles = params['rewindFiles'] !== false;
if (rewindFiles && promptId) {
const fhs = session.getConfig().getFileHistoryService();
try {
Expand Down Expand Up @@ -7158,7 +7163,8 @@ class QwenAgent implements Agent {

private async createAndStoreSession(
config: Config,
conversation?: ConversationRecord,
sessionData?: ResumedSessionData,
options: { replayHistory?: boolean } = {},
): Promise<Session> {
const sessionId = config.getSessionId();
const geminiClient = config.getGeminiClient();
Expand All @@ -7182,8 +7188,14 @@ class QwenAgent implements Agent {
await session.sendAvailableCommandsUpdate();
}, 0);

if (conversation && conversation.messages) {
await session.replayHistory(conversation.messages);
if (sessionData?.fileHistorySnapshots?.length) {
config
.getFileHistoryService()
.restoreFromSnapshots(sessionData.fileHistorySnapshots);
}

if (options.replayHistory !== false && sessionData?.conversation.messages) {
await session.replayHistory(sessionData.conversation.messages);
}

// Install rewriter AFTER history replay to avoid rewriting historical messages
Expand Down
111 changes: 111 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
import type { LoadedSettings } from '../../config/settings.js';
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
import { CommandKind } from '../../ui/commands/types.js';
import { MessageType } from '../../ui/types.js';

const debugLoggerWarnSpy = vi.hoisted(() => vi.fn());

Expand Down Expand Up @@ -506,6 +507,37 @@ describe('Session', () => {
);
});

it('can rewind the conversation without restoring file history', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'first' }] },
{ role: 'model', parts: [{ text: 'first reply' }] },
{ role: 'user', parts: [{ text: 'second' }] },
{ role: 'model', parts: [{ text: 'second reply' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history);
vi.mocked(mockFileHistoryService.getSnapshots).mockReturnValue([
{
promptId: 'p1',
timestamp: new Date('2026-06-13T00:00:00.000Z'),
trackedFileBackups: {},
},
]);

const result = session.rewindToTurn(1, { rewindFiles: false });

expect(result).toEqual({ targetTurnIndex: 1, apiTruncateIndex: 2 });
expect(mockChat.truncateHistory).toHaveBeenCalledWith(2);
expect(
mockFileHistoryService.restoreFromSnapshots,
).not.toHaveBeenCalled();
expect(mockChatRecordingService.rewindRecording).toHaveBeenCalledWith(
1,
{ truncatedCount: 2 },
undefined,
);
});

it('preserves startup context when rewinding to the first user turn', () => {
const history: Content[] = [
{
Expand All @@ -528,6 +560,33 @@ describe('Session', () => {
expect(mockChat.truncateHistory).toHaveBeenCalledWith(1);
});

it('counts only real user prompts as rewindable turns', () => {
const history: Content[] = [
{
role: 'user',
parts: [
{
text: `${SYSTEM_REMINDER_OPEN}\nstartup context\n${SYSTEM_REMINDER_CLOSE}`,
},
],
},
{ role: 'user', parts: [{ text: 'first' }] },
{ role: 'model', parts: [{ text: 'first reply' }] },
{
role: 'user',
parts: [
{
text: `${SYSTEM_REMINDER_OPEN}\nNew tools available: foo\n${SYSTEM_REMINDER_CLOSE}`,
},
],
},
{ role: 'user', parts: [{ text: 'second' }] },
];
vi.mocked(mockChat.getHistoryShallow).mockReturnValue(history);

expect(session.getRewindableUserTurnCount()).toBe(2);
});

it('does not count a mid-history MCP added-tool reminder as a user turn', () => {
// drainPendingAddedMcpToolsReminder injects a pure <system-reminder>
// user entry mid-history. Counting it as a real turn would land the
Expand Down Expand Up @@ -4319,6 +4378,58 @@ describe('Session', () => {
expect(mockGeminiClient.tryCompressChat).not.toHaveBeenCalled();
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});

it('keeps goal terminal observer after ACP /goal set', async () => {
vi.mocked(
nonInteractiveCliCommands.handleSlashCommand,
).mockResolvedValueOnce({
type: 'submit_prompt',
content: [{ text: 'Continue until the goal is met.' }],
outputHistoryItems: [
{
type: MessageType.GOAL_STATUS,
kind: 'set',
condition: 'check weather',
setAt: 1234,
},
],
});
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: '/goal check weather' }],
});

core.notifyGoalTerminal('test-session-id', {
kind: 'achieved',
condition: 'check weather',
iterations: 1,
durationMs: 5000,
lastReason: 'Weather checked.',
});

await vi.waitFor(() => {
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
goalTerminal: {
kind: 'achieved',
condition: 'check weather',
iterations: 1,
durationMs: 5000,
lastReason: 'Weather checked.',
},
},
},
});
});
});
});

it('passes resolved paths to read_many_files tool', async () => {
Expand Down
Loading
Loading