mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(core): exclude completed local slash commands from recovery (#12052)
Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
1 parent
9dd689f048
commit
293a5b8112
4 files changed
+274
-1
No files matched your search
@@ -7,6 +7,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ChatRecord } from './chatRecordingService.js';
|
||||
import { CompressionStatus } from '../core/turn.js';
|
||||
import { detectTurnInterruption } from '../core/turn-interruption.js';
|
||||
import {
|
||||
buildApiHistoryFromConversation,
|
||||
buildSessionHistoryFromConversation,
|
||||
@@ -14,6 +15,153 @@ import {
|
||||
|
||||
const permit = { goalId: 'goal', revision: 1, turnId: 'turn' };
|
||||
|
||||
describe('completed local slash commands', () => {
|
||||
function commandRecords(command = '/docs'): ChatRecord[] {
|
||||
const base = {
|
||||
sessionId: 'session',
|
||||
timestamp: '2026-09-17T00:00:00.000Z',
|
||||
cwd: '/workspace',
|
||||
version: 'test',
|
||||
};
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
uuid: 'user',
|
||||
parentUuid: null,
|
||||
type: 'user',
|
||||
message: { role: 'user', parts: [{ text: command }] },
|
||||
},
|
||||
{
|
||||
...base,
|
||||
uuid: 'output',
|
||||
parentUuid: 'user',
|
||||
type: 'system',
|
||||
subtype: 'slash_command',
|
||||
systemPayload: {
|
||||
phase: 'result',
|
||||
rawCommand: command,
|
||||
outputHistoryItems: [{ type: 'assistant', text: 'Done.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
it.each(['/docs', '/export md', '/effort', '/summary', '/model --fast test'])(
|
||||
'excludes completed %s from model history without changing the transcript',
|
||||
(command) => {
|
||||
const messages = commandRecords(command);
|
||||
const original = structuredClone(messages);
|
||||
const history = buildApiHistoryFromConversation({ messages });
|
||||
expect(history).toEqual([]);
|
||||
expect(detectTurnInterruption(history).kind).toBe('none');
|
||||
expect(messages).toEqual(original);
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves unanswered input before and after a completed command', () => {
|
||||
const [user, output] = commandRecords();
|
||||
const pending: ChatRecord = {
|
||||
...user,
|
||||
uuid: 'pending',
|
||||
message: { role: 'user', parts: [{ text: 'unfinished request' }] },
|
||||
};
|
||||
for (const messages of [
|
||||
[pending, user, output, output],
|
||||
[user, output, pending],
|
||||
]) {
|
||||
const history = buildApiHistoryFromConversation({ messages });
|
||||
expect(history).toEqual([pending.message]);
|
||||
expect(detectTurnInterruption(history).kind).toBe('interrupted_prompt');
|
||||
}
|
||||
expect(buildApiHistoryFromConversation({ messages: [user] })).toEqual([
|
||||
user.message,
|
||||
]);
|
||||
expect(
|
||||
buildApiHistoryFromConversation({ messages: [user, pending, output] }),
|
||||
).toEqual([user.message, pending.message]);
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'does not pair a TUI invocation (sentToModel=%s) with old input',
|
||||
(sentToModel) => {
|
||||
const [user, output] = commandRecords('/custom');
|
||||
const invocation: ChatRecord = {
|
||||
...output,
|
||||
uuid: 'invocation',
|
||||
systemPayload: {
|
||||
phase: 'invocation',
|
||||
rawCommand: '/custom',
|
||||
sentToModel,
|
||||
},
|
||||
};
|
||||
expect(
|
||||
buildApiHistoryFromConversation({
|
||||
messages: [user, invocation, output],
|
||||
}),
|
||||
).toEqual([user.message]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['info', 'away_recap', 'error'])(
|
||||
'does not mistake %s output for an ACP command result',
|
||||
(type) => {
|
||||
const [user, output] = commandRecords();
|
||||
output.systemPayload = {
|
||||
phase: 'result',
|
||||
rawCommand: '/docs',
|
||||
outputHistoryItems: [{ type, text: 'display only' }],
|
||||
};
|
||||
expect(
|
||||
buildApiHistoryFromConversation({ messages: [user, output] }),
|
||||
).toEqual([user.message]);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not discard unrelated results or merged mid-turn input', () => {
|
||||
const [user, output] = commandRecords();
|
||||
const unrelated = commandRecords('/other')[1];
|
||||
expect(
|
||||
buildApiHistoryFromConversation({ messages: [user, unrelated] }),
|
||||
).toEqual([user.message]);
|
||||
const midTurn: ChatRecord = {
|
||||
...user,
|
||||
uuid: 'mid',
|
||||
subtype: 'mid_turn_user_message',
|
||||
};
|
||||
expect(
|
||||
buildApiHistoryFromConversation({ messages: [user, midTurn, output] }),
|
||||
).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [...user.message!.parts!, ...midTurn.message!.parts!],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not pop a compression snapshot when the old command result arrives', () => {
|
||||
const [user, output] = commandRecords();
|
||||
const compressedHistory = [{ role: 'model', parts: [{ text: 'summary' }] }];
|
||||
const compression: ChatRecord = {
|
||||
...output,
|
||||
uuid: 'compression',
|
||||
subtype: 'chat_compression',
|
||||
systemPayload: {
|
||||
info: {
|
||||
originalTokenCount: 100,
|
||||
newTokenCount: 50,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
},
|
||||
compressedHistory,
|
||||
},
|
||||
};
|
||||
expect(
|
||||
buildApiHistoryFromConversation({
|
||||
messages: [user, compression, output],
|
||||
}),
|
||||
).toEqual(compressedHistory);
|
||||
});
|
||||
});
|
||||
|
||||
function records(toolCallId = 'finish'): ChatRecord[] {
|
||||
const base = {
|
||||
sessionId: 'session',
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ChatCompressionRecordPayload,
|
||||
ChatRecord,
|
||||
GoalTurnEndRecordPayload,
|
||||
SlashCommandRecordPayload,
|
||||
} from './chatRecordingService.js';
|
||||
|
||||
export interface BuildApiHistoryOptions {
|
||||
@@ -103,6 +104,36 @@ export class SessionApiHistoryAccumulator {
|
||||
|
||||
add(record: ChatRecord): void {
|
||||
if (record.type === 'system') {
|
||||
if (record.subtype === 'slash_command') {
|
||||
const payload = record.systemPayload as
|
||||
| SlashCommandRecordPayload
|
||||
| undefined;
|
||||
const previous = this.lastMaterialRecord;
|
||||
const parts = previous?.message?.parts;
|
||||
// ACP records local command input as user and its output as a system
|
||||
// result. Neither belongs in model history. TUI invocations fence off
|
||||
// earlier input, including custom commands submitted to the model.
|
||||
if (
|
||||
payload?.phase === 'result' &&
|
||||
payload.sentToModel !== true &&
|
||||
Array.isArray(payload.outputHistoryItems) &&
|
||||
payload.outputHistoryItems.length > 0 &&
|
||||
payload.outputHistoryItems.every(
|
||||
(item) => item?.['type'] === 'assistant',
|
||||
) &&
|
||||
previous?.type === 'user' &&
|
||||
previous.subtype === undefined &&
|
||||
previous.message?.role === 'user' &&
|
||||
parts?.length === 1 &&
|
||||
typeof parts[0].text === 'string' &&
|
||||
Object.keys(parts[0]).length === 1 &&
|
||||
parts[0].text === payload.rawCommand
|
||||
) {
|
||||
this.history.pop();
|
||||
}
|
||||
if (previous?.type === 'user') this.lastMaterialRecord = undefined;
|
||||
return;
|
||||
}
|
||||
if (record.subtype === 'goal_turn_end') {
|
||||
const payload = record.systemPayload as
|
||||
| GoalTurnEndRecordPayload
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
import { Storage } from '../config/storage.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { CompressionStatus } from '../core/turn.js';
|
||||
import { detectTurnInterruption } from '../core/turn-interruption.js';
|
||||
import {
|
||||
SessionSourceService,
|
||||
type SessionSourcesSnapshot,
|
||||
@@ -129,6 +130,97 @@ describe('SessionTranscriptReader', () => {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
'restores completed slash commands consistently with compression=%s',
|
||||
async (compressed) => {
|
||||
const answer = record('a1', null, 'previous answer');
|
||||
const prefix: ChatRecord = compressed
|
||||
? {
|
||||
...answer,
|
||||
type: 'system',
|
||||
subtype: 'chat_compression',
|
||||
message: undefined,
|
||||
systemPayload: {
|
||||
info: {
|
||||
originalTokenCount: 100,
|
||||
newTokenCount: 50,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
},
|
||||
compressedHistory: [answer.message!],
|
||||
},
|
||||
}
|
||||
: answer;
|
||||
const user = record('u1', 'a1', '/docs');
|
||||
const output: ChatRecord = {
|
||||
...record('output', 'u1', ''),
|
||||
type: 'system',
|
||||
subtype: 'slash_command',
|
||||
message: undefined,
|
||||
systemPayload: {
|
||||
phase: 'result',
|
||||
rawCommand: '/docs',
|
||||
outputHistoryItems: [
|
||||
{ type: 'assistant', text: 'Documentation URL' },
|
||||
],
|
||||
},
|
||||
};
|
||||
await writeRecords([prefix, user, output]);
|
||||
const service = new SessionService(workspaceDir, {
|
||||
runtimeBaseDir: runtimeDir,
|
||||
});
|
||||
const loaded = await service.loadSession(sessionId);
|
||||
const history = buildApiHistoryFromConversation(loaded!.conversation);
|
||||
expect(history).toEqual([answer.message]);
|
||||
expect(detectTurnInterruption(history).kind).toBe('none');
|
||||
for (const replay of [
|
||||
{ kind: 'none' },
|
||||
{ kind: 'all', hideInheritedHistory: false },
|
||||
] as const) {
|
||||
const projection = await service.readRestoreProjection(sessionId, {
|
||||
replay,
|
||||
});
|
||||
expect(projection?.runtime.apiHistory).toEqual(history);
|
||||
if (replay.kind === 'all') {
|
||||
expect(projection?.replay?.records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ uuid: 'u1', message: user.message }),
|
||||
expect.objectContaining({
|
||||
uuid: 'output',
|
||||
systemPayload: output.systemPayload,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const invocation: ChatRecord = {
|
||||
...output,
|
||||
uuid: 'invocation',
|
||||
systemPayload: {
|
||||
phase: 'invocation',
|
||||
rawCommand: '/docs',
|
||||
sentToModel: true,
|
||||
},
|
||||
};
|
||||
await writeRecords([
|
||||
prefix,
|
||||
user,
|
||||
invocation,
|
||||
{ ...output, parentUuid: 'invocation' },
|
||||
]);
|
||||
const custom = await service.readRestoreProjection(sessionId, {
|
||||
replay: { kind: 'none' },
|
||||
});
|
||||
expect(custom?.runtime.apiHistory).toEqual([
|
||||
answer.message,
|
||||
user.message,
|
||||
]);
|
||||
expect(detectTurnInterruption(custom!.runtime.apiHistory).kind).toBe(
|
||||
'interrupted_prompt',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('round-trips a recorded Goal turn end through selective restore, fork, and rewind', async () => {
|
||||
const config = {
|
||||
storage: new Storage(workspaceDir),
|
||||
|
||||
@@ -2729,7 +2729,9 @@ export class SessionTranscriptReader {
|
||||
const entry = index.byUuid.get(uuid);
|
||||
if (
|
||||
position === compressionPosition ||
|
||||
((entry?.type !== 'system' || entry?.subtype === 'goal_turn_end') &&
|
||||
((entry?.type !== 'system' ||
|
||||
entry?.subtype === 'goal_turn_end' ||
|
||||
entry?.subtype === 'slash_command') &&
|
||||
(compressionPosition < 0 || position > compressionPosition))
|
||||
) {
|
||||
modelSet.add(uuid);
|
||||
|
||||
Reference in new issue
Block a user