Skip to content

Commit d80631f

Browse files
committed
feat(会话恢复): 实现会话恢复时保留原始上下文消息
重构会话恢复逻辑,新增 SessionService.toUISafeMessages 方法统一处理消息转换 新增 restoredContextMessages 和 restoredVisibleMessageCount 状态保存原始上下文 新增 buildContextMessagesFromSession 方法构建发送给 Agent 的上下文消息 添加相关单元测试确保功能正确性
1 parent 8c15406 commit d80631f

9 files changed

Lines changed: 201 additions & 79 deletions

File tree

packages/cli/src/services/SessionService.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from '../context/storage/pathUtils.js';
1313
import type { SessionEvent } from '../context/types.js';
1414
import { createLogger, LogCategory } from '../logging/Logger.js';
15-
import type { JsonValue } from '../store/types.js';
15+
import type { JsonValue, SessionMessage } from '../store/types.js';
1616
import type { ContentPart, Message } from './ChatServiceInterface.js';
1717

1818
const logger = createLogger(LogCategory.SERVICE);
@@ -40,6 +40,53 @@ export interface SessionMetadata {
4040
* 会话管理服务
4141
*/
4242
export class SessionService {
43+
/**
44+
* 将加载到的会话消息转换为 UI 安全的 SessionMessage。
45+
* 过滤掉 tool / system 等内部消息,仅从 ContentPart[] 中提取文本,
46+
* 避免把 </functions>、工具调用 JSON、summary 等内部内容泄露给用户或污染历史。
47+
*/
48+
static toUISafeMessages(messages: Message[]): SessionMessage[] {
49+
const now = Date.now();
50+
const total = messages.length;
51+
const result: SessionMessage[] = [];
52+
53+
messages.forEach((msg, index) => {
54+
if (msg.role !== 'user' && msg.role !== 'assistant') return;
55+
56+
let content: string;
57+
if (typeof msg.content === 'string') {
58+
content = msg.content;
59+
} else if (Array.isArray(msg.content)) {
60+
content = (msg.content as ContentPart[])
61+
.map((part) => (part.type === 'text' ? part.text : '[Image]'))
62+
.join('');
63+
} else {
64+
content = '';
65+
}
66+
67+
const normalizedContent = content.trim();
68+
if (!normalizedContent) return;
69+
70+
const previous = result[result.length - 1];
71+
if (previous && previous.role === msg.role && previous.content === normalizedContent) {
72+
return;
73+
}
74+
75+
result.push({
76+
id: `restored-${now}-${index}`,
77+
role: msg.role,
78+
content: normalizedContent,
79+
timestamp: now - (total - index) * 1000,
80+
metadata:
81+
msg.metadata && typeof msg.metadata === 'object'
82+
? (msg.metadata as Record<string, unknown>)
83+
: undefined,
84+
});
85+
});
86+
87+
return result;
88+
}
89+
4390
/**
4491
* 列出所有可用会话
4592
* 扫描 ~/.blade/projects/ 目录下的所有 JSONL 文件

packages/cli/src/slash-commands/resume.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,10 @@ const resumeCommand: SlashCommand = {
4444
};
4545
}
4646

47-
// 转换为 SessionMessage 格式并恢复会话
48-
const sessionMessages = messages
49-
.filter((msg) => msg.role !== 'tool')
50-
.map((msg, index) => ({
51-
id: `restored-${Date.now()}-${index}`,
52-
role: msg.role,
53-
content:
54-
typeof msg.content === 'string'
55-
? msg.content
56-
: JSON.stringify(msg.content),
57-
timestamp: Date.now() - (messages.length - index) * 1000,
58-
}));
47+
// 转换为 SessionMessage 格式并恢复会话(共用 UI-safe 归一化,过滤 tool/system 等内部消息)
48+
const sessionMessages = SessionService.toUISafeMessages(messages);
5949

60-
restoreSession(sessionId, sessionMessages);
50+
restoreSession(sessionId, sessionMessages, messages);
6151

6252
ui.sendMessage(
6353
`[OK] 已恢复会话 \`${sessionId}\`\n\n共 ${sessionMessages.length} 条消息已加载,可以继续对话`

packages/cli/src/store/slices/sessionSlice.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import { nanoid } from 'nanoid';
1313
import type { StateCreator } from 'zustand';
14+
import type { Message } from '../../services/ChatServiceInterface.js';
1415
import { clearAllMarkdownCache } from '../../ui/utils/markdownIncremental.js';
1516
import type {
1617
BladeStore,
@@ -53,6 +54,8 @@ const initialTokenUsage: TokenUsage = {
5354
const initialSessionState: SessionState = {
5455
sessionId: nanoid(),
5556
messages: [],
57+
restoredContextMessages: null,
58+
restoredVisibleMessageCount: 0,
5659
isCompacting: false,
5760
currentCommand: null,
5861
error: null,
@@ -203,6 +206,8 @@ export const createSessionSlice: StateCreator<BladeStore, [], [], SessionSlice>
203206
session: {
204207
...state.session,
205208
messages: [],
209+
restoredContextMessages: null,
210+
restoredVisibleMessageCount: 0,
206211
error: null,
207212
clearCount: state.session.clearCount + 1,
208213
},
@@ -227,13 +232,19 @@ export const createSessionSlice: StateCreator<BladeStore, [], [], SessionSlice>
227232
/**
228233
* 恢复会话
229234
*/
230-
restoreSession: (sessionId: string, messages: SessionMessage[]) => {
235+
restoreSession: (
236+
sessionId: string,
237+
messages: SessionMessage[],
238+
restoredContextMessages?: Message[]
239+
) => {
231240
clearAllMarkdownCache();
232241
set((state) => ({
233242
session: {
234243
...state.session,
235244
sessionId,
236245
messages,
246+
restoredContextMessages: restoredContextMessages ?? null,
247+
restoredVisibleMessageCount: restoredContextMessages ? messages.length : 0,
237248
error: null,
238249
isActive: true,
239250
},

packages/cli/src/store/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import type { ModelConfig, RuntimeConfig } from '../config/types.js';
1212
import { PermissionMode } from '../config/types.js';
13+
import type { Message } from '../services/ChatServiceInterface.js';
1314
import type { SessionMetadata } from '../services/SessionService.js';
1415
import type { TodoItem } from '../tools/builtin/todo/types.js';
1516
import type { SpecSlice } from './slices/specSlice.js';
@@ -68,6 +69,8 @@ export interface TokenUsage {
6869
export interface SessionState {
6970
sessionId: string;
7071
messages: SessionMessage[];
72+
restoredContextMessages: Message[] | null; // resume 时保留的原始上下文(含 summary / multimodal)
73+
restoredVisibleMessageCount: number; // messages 中来自 restoreSession 的可见消息数
7174
isCompacting: boolean; // 是否正在压缩上下文
7275
currentCommand: string | null;
7376
error: string | null;
@@ -104,7 +107,11 @@ export interface SessionActions {
104107
setError: (error: string | null) => void;
105108
clearMessages: () => void;
106109
resetSession: () => void;
107-
restoreSession: (sessionId: string, messages: SessionMessage[]) => void;
110+
restoreSession: (
111+
sessionId: string,
112+
messages: SessionMessage[],
113+
restoredContextMessages?: Message[]
114+
) => void;
108115
updateTokenUsage: (usage: Partial<TokenUsage>) => void;
109116
resetTokenUsage: () => void;
110117
// Thinking 相关 actions

packages/cli/src/ui/components/BladeInterface.tsx

Lines changed: 8 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -283,19 +283,9 @@ export const BladeInterface: React.FC<BladeInterfaceProps> = ({
283283
const mostRecentSession = sessions[0];
284284
const messages = await SessionService.loadSession(mostRecentSession.sessionId);
285285

286-
const sessionMessages = messages.map((msg, index) => ({
287-
id: `restored-${Date.now()}-${index}`,
288-
role: msg.role,
289-
content:
290-
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
291-
timestamp: Date.now() - (messages.length - index) * 1000,
292-
metadata:
293-
msg.metadata && typeof msg.metadata === 'object'
294-
? (msg.metadata as Record<string, unknown>)
295-
: undefined,
296-
}));
297-
298-
sessionActions.restoreSession(mostRecentSession.sessionId, sessionMessages);
286+
const sessionMessages = SessionService.toUISafeMessages(messages);
287+
288+
sessionActions.restoreSession(mostRecentSession.sessionId, sessionMessages, messages);
299289
} catch (error) {
300290
logger.error('[BladeInterface] 继续会话失败:', error);
301291
sessionActions.addAssistantMessage('继续会话失败,开始新对话。');
@@ -308,19 +298,9 @@ export const BladeInterface: React.FC<BladeInterfaceProps> = ({
308298
if (typeof otherProps.resume === 'string' && otherProps.resume !== 'true') {
309299
const messages = await SessionService.loadSession(otherProps.resume);
310300

311-
const sessionMessages = messages.map((msg, index) => ({
312-
id: `restored-${Date.now()}-${index}`,
313-
role: msg.role,
314-
content:
315-
typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
316-
timestamp: Date.now() - (messages.length - index) * 1000,
317-
metadata:
318-
msg.metadata && typeof msg.metadata === 'object'
319-
? (msg.metadata as Record<string, unknown>)
320-
: undefined,
321-
}));
322-
323-
sessionActions.restoreSession(otherProps.resume, sessionMessages);
301+
const sessionMessages = SessionService.toUISafeMessages(messages);
302+
303+
sessionActions.restoreSession(otherProps.resume, sessionMessages, messages);
324304
return;
325305
}
326306

@@ -392,38 +372,9 @@ export const BladeInterface: React.FC<BladeInterfaceProps> = ({
392372
try {
393373
const messages = await SessionService.loadSession(sessionId);
394374

395-
// 过滤并转换消息:只保留用户和助手的消息,过滤掉 tool 和 system 消息
396-
const sessionMessages = messages
397-
.filter((msg) => msg.role === 'user' || msg.role === 'assistant')
398-
.map((msg, index) => {
399-
// 提取消息内容:如果是 ContentPart[] 数组,只提取文本部分
400-
let content: string;
401-
if (typeof msg.content === 'string') {
402-
content = msg.content;
403-
} else if (Array.isArray(msg.content)) {
404-
// 从 ContentPart[] 中提取文本
405-
content = msg.content
406-
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
407-
.map((part) => part.text)
408-
.join('');
409-
} else {
410-
// 其他情况(不应该发生)
411-
content = '';
412-
}
375+
const sessionMessages = SessionService.toUISafeMessages(messages);
413376

414-
return {
415-
id: `restored-${Date.now()}-${index}`,
416-
role: msg.role,
417-
content,
418-
timestamp: Date.now() - (messages.length - index) * 1000,
419-
metadata:
420-
msg.metadata && typeof msg.metadata === 'object'
421-
? (msg.metadata as Record<string, unknown>)
422-
: undefined,
423-
};
424-
});
425-
426-
sessionActions.restoreSession(sessionId, sessionMessages);
377+
sessionActions.restoreSession(sessionId, sessionMessages, messages);
427378
appActions.closeModal();
428379
} catch (error) {
429380
logger.error('[BladeInterface] Failed to restore session:', error);

packages/cli/src/ui/hooks/useCommandHandler.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
useAppActions,
2727
useCommandActions,
2828
useIsProcessing,
29-
useMessages,
3029
usePermissionMode,
3130
useSessionActions,
3231
useSessionId,
@@ -41,6 +40,7 @@ import {
4140
} from '../utils/markdownIncremental.js';
4241
import { classifyError } from '../utils/errorExtractor.js';
4342
import { buildUserMessageContent } from '../utils/messageContent.js';
43+
import { buildContextMessagesFromSession } from '../utils/sessionContext.js';
4444
import { processSlashCommand, type CommandResult } from '../utils/slashCommandRouter.js';
4545
import { createLoopEventHandler } from '../utils/loopEventHandler.js';
4646
import { useAgent } from './useAgent.js';
@@ -62,7 +62,6 @@ export const useCommandHandler = (
6262
) => {
6363
// ==================== Store 选择器 ====================
6464
const isProcessing = useIsProcessing();
65-
const messages = useMessages();
6665
const sessionId = useSessionId();
6766
const permissionMode = usePermissionMode();
6867
const thinkingModeEnabled = useThinkingModeEnabled();
@@ -230,10 +229,7 @@ export const useCommandHandler = (
230229
return { success: false, error: 'aborted' };
231230
}
232231

233-
const contextMessages = messages.map((msg) => ({
234-
role: msg.role,
235-
content: msg.content,
236-
}));
232+
const contextMessages = buildContextMessagesFromSession(getState().session);
237233

238234
if (hookContextInjection) {
239235
contextMessages.push({
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Message } from '../../services/ChatServiceInterface.js';
2+
import type { SessionMessage, SessionState } from '../../store/types.js';
3+
4+
function toContextMessage(message: SessionMessage): Message {
5+
return {
6+
role: message.role,
7+
content: message.content,
8+
};
9+
}
10+
11+
/**
12+
* 构建发送给 Agent 的上下文消息。
13+
* - 普通会话:直接使用当前 UI 消息
14+
* - resume 会话:使用恢复时保存的原始消息(保留 summary / multimodal),
15+
* 再拼接恢复后新增的 UI 消息,避免丢上下文或重复历史
16+
*/
17+
export function buildContextMessagesFromSession(
18+
session: Pick<SessionState, 'messages' | 'restoredContextMessages' | 'restoredVisibleMessageCount'>
19+
): Message[] {
20+
if (!session.restoredContextMessages || session.restoredVisibleMessageCount <= 0) {
21+
return session.messages.map(toContextMessage);
22+
}
23+
24+
const appendedMessages = session.messages
25+
.slice(session.restoredVisibleMessageCount)
26+
.map(toContextMessage);
27+
28+
return [...session.restoredContextMessages, ...appendedMessages];
29+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { Message } from '../../../src/services/ChatServiceInterface.js';
3+
import { SessionService } from '../../../src/services/SessionService.js';
4+
5+
describe('SessionService.toUISafeMessages', () => {
6+
it('filters internal messages while preserving user-visible multimodal placeholders', () => {
7+
const messages: Message[] = [
8+
{ role: 'system', content: 'internal summary' },
9+
{
10+
role: 'user',
11+
content: [
12+
{ type: 'text', text: 'Look at ' },
13+
{ type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } },
14+
],
15+
},
16+
{
17+
role: 'assistant',
18+
content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,def' } }],
19+
},
20+
{ role: 'tool', content: '{"secret":"tool-json"}' },
21+
{ role: 'assistant', content: 'Done' },
22+
];
23+
24+
expect(SessionService.toUISafeMessages(messages)).toMatchObject([
25+
{ role: 'user', content: 'Look at [Image]' },
26+
{ role: 'assistant', content: '[Image]' },
27+
{ role: 'assistant', content: 'Done' },
28+
]);
29+
});
30+
31+
it('drops consecutive duplicate visible messages during resume normalization', () => {
32+
const messages: Message[] = [
33+
{ role: 'user', content: 'same prompt' },
34+
{ role: 'user', content: 'same prompt' },
35+
{ role: 'assistant', content: 'same answer' },
36+
{ role: 'assistant', content: 'same answer' },
37+
];
38+
39+
expect(SessionService.toUISafeMessages(messages)).toMatchObject([
40+
{ role: 'user', content: 'same prompt' },
41+
{ role: 'assistant', content: 'same answer' },
42+
]);
43+
});
44+
});

0 commit comments

Comments
 (0)