Session-Level Conversation History Management (#1113)

This commit is contained in:
tanzhenxin
2025-12-03 18:04:48 +08:00
committed by GitHub
parent a7abd8d09f
commit 0a75d85ac9
114 changed files with 9257 additions and 4039 deletions

View File

@@ -0,0 +1,137 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { ChatRecord } from '@qwen-code/qwen-code-core';
import type { Content } from '@google/genai';
import type { SessionContext } from './types.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
/**
* Handles replaying session history on session load.
*
* Uses the unified emitters to ensure consistency with normal flow.
* This ensures that replayed history looks identical to how it would
* have appeared during the original session.
*/
export class HistoryReplayer {
private readonly messageEmitter: MessageEmitter;
private readonly toolCallEmitter: ToolCallEmitter;
constructor(ctx: SessionContext) {
this.messageEmitter = new MessageEmitter(ctx);
this.toolCallEmitter = new ToolCallEmitter(ctx);
}
/**
* Replays all chat records from a loaded session.
*
* @param records - Array of chat records to replay
*/
async replay(records: ChatRecord[]): Promise<void> {
for (const record of records) {
await this.replayRecord(record);
}
}
/**
* Replays a single chat record.
*/
private async replayRecord(record: ChatRecord): Promise<void> {
switch (record.type) {
case 'user':
if (record.message) {
await this.replayContent(record.message, 'user');
}
break;
case 'assistant':
if (record.message) {
await this.replayContent(record.message, 'assistant');
}
break;
case 'tool_result':
await this.replayToolResult(record);
break;
default:
// Skip system records (compression, telemetry, slash commands)
break;
}
}
/**
* Replays content from a message (user or assistant).
* Handles text parts, thought parts, and function calls.
*/
private async replayContent(
content: Content,
role: 'user' | 'assistant',
): Promise<void> {
for (const part of content.parts ?? []) {
// Text content
if ('text' in part && part.text) {
const isThought = (part as { thought?: boolean }).thought ?? false;
await this.messageEmitter.emitMessage(part.text, role, isThought);
}
// Function call (tool start)
if ('functionCall' in part && part.functionCall) {
const functionName = part.functionCall.name ?? '';
const callId = part.functionCall.id ?? `${functionName}-${Date.now()}`;
await this.toolCallEmitter.emitStart({
toolName: functionName,
callId,
args: part.functionCall.args as Record<string, unknown>,
});
}
}
}
/**
* Replays a tool result record.
*/
private async replayToolResult(record: ChatRecord): Promise<void> {
// message is required - skip if not present
if (!record.message?.parts) {
return;
}
const result = record.toolCallResult;
const callId = result?.callId ?? record.uuid;
// Extract tool name from the function response in message if available
const toolName = this.extractToolNameFromRecord(record);
await this.toolCallEmitter.emitResult({
toolName,
callId,
success: !result?.error,
message: record.message.parts,
resultDisplay: result?.resultDisplay,
// For TodoWriteTool fallback, try to extract args from the record
// Note: args aren't stored in tool_result records by default
args: undefined,
});
}
/**
* Extracts tool name from a chat record's function response.
*/
private extractToolNameFromRecord(record: ChatRecord): string {
// Try to get from functionResponse in message
if (record.message?.parts) {
for (const part of record.message.parts) {
if ('functionResponse' in part && part.functionResponse?.name) {
return part.functionResponse.name;
}
}
}
return '';
}
}