mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Session-Level Conversation History Management (#1113)
This commit is contained in:
299
packages/cli/src/ui/utils/resumeHistoryUtils.ts
Normal file
299
packages/cli/src/ui/utils/resumeHistoryUtils.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Part, FunctionCall } from '@google/genai';
|
||||
import type {
|
||||
ResumedSessionData,
|
||||
ConversationRecord,
|
||||
Config,
|
||||
AnyDeclarativeTool,
|
||||
ToolResultDisplay,
|
||||
SlashCommandRecordPayload,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
|
||||
/**
|
||||
* Extracts text content from a Content object's parts.
|
||||
*/
|
||||
function extractTextFromParts(parts: Part[] | undefined): string {
|
||||
if (!parts) return '';
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const part of parts) {
|
||||
if ('text' in part && part.text) {
|
||||
// Skip thought parts - they have a 'thought' property
|
||||
if (!('thought' in part && part.thought)) {
|
||||
textParts.push(part.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return textParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts function calls from a Content object's parts.
|
||||
*/
|
||||
function extractFunctionCalls(
|
||||
parts: Part[] | undefined,
|
||||
): Array<{ id: string; name: string; args: Record<string, unknown> }> {
|
||||
if (!parts) return [];
|
||||
|
||||
const calls: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}> = [];
|
||||
for (const part of parts) {
|
||||
if ('functionCall' in part && part.functionCall) {
|
||||
const fc = part.functionCall as FunctionCall;
|
||||
calls.push({
|
||||
id: fc.id || `call-${calls.length}`,
|
||||
name: fc.name || 'unknown',
|
||||
args: (fc.args as Record<string, unknown>) || {},
|
||||
});
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
function getTool(config: Config, name: string): AnyDeclarativeTool | undefined {
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
return toolRegistry.getTool(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a tool description from its name and arguments using actual tool instances.
|
||||
* This ensures we get the exact same descriptions as during normal operation.
|
||||
*/
|
||||
function formatToolDescription(
|
||||
tool: AnyDeclarativeTool,
|
||||
args: Record<string, unknown>,
|
||||
): string {
|
||||
try {
|
||||
// Create tool invocation instance and get description
|
||||
const invocation = tool.build(args);
|
||||
return invocation.getDescription();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a HistoryItemWithoutId from the serialized shape stored in
|
||||
* SlashCommandRecordPayload.outputHistoryItems.
|
||||
*/
|
||||
function restoreHistoryItem(raw: unknown): HistoryItemWithoutId | undefined {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const clone = { ...(raw as Record<string, unknown>) };
|
||||
if ('timestamp' in clone) {
|
||||
const ts = clone['timestamp'];
|
||||
if (typeof ts === 'string' || typeof ts === 'number') {
|
||||
clone['timestamp'] = new Date(ts);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof clone['type'] !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
return clone as unknown as HistoryItemWithoutId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts ChatRecord messages to UI history items for display.
|
||||
*
|
||||
* This function transforms the raw ChatRecords into a format suitable
|
||||
* for the CLI's HistoryItemDisplay component.
|
||||
*
|
||||
* @param conversation The conversation record from a resumed session
|
||||
* @param config The config object for accessing tool registry
|
||||
* @returns Array of history items for UI display
|
||||
*/
|
||||
function convertToHistoryItems(
|
||||
conversation: ConversationRecord,
|
||||
config: Config,
|
||||
): HistoryItemWithoutId[] {
|
||||
const items: HistoryItemWithoutId[] = [];
|
||||
|
||||
// Track pending tool calls for grouping with results
|
||||
const pendingToolCalls = new Map<
|
||||
string,
|
||||
{ name: string; args: Record<string, unknown> }
|
||||
>();
|
||||
let currentToolGroup: Array<{
|
||||
callId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
status: ToolCallStatus;
|
||||
confirmationDetails: undefined;
|
||||
}> = [];
|
||||
|
||||
for (const record of conversation.messages) {
|
||||
if (record.type === 'system') {
|
||||
if (record.subtype === 'slash_command') {
|
||||
// Flush any pending tool group to avoid mixing contexts.
|
||||
if (currentToolGroup.length > 0) {
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: [...currentToolGroup],
|
||||
});
|
||||
currentToolGroup = [];
|
||||
}
|
||||
const payload = record.systemPayload as
|
||||
| SlashCommandRecordPayload
|
||||
| undefined;
|
||||
if (!payload) continue;
|
||||
if (payload.phase === 'invocation' && payload.rawCommand) {
|
||||
items.push({ type: 'user', text: payload.rawCommand });
|
||||
}
|
||||
if (payload.phase === 'result') {
|
||||
const outputs = payload.outputHistoryItems ?? [];
|
||||
for (const raw of outputs) {
|
||||
const restored = restoreHistoryItem(raw);
|
||||
if (restored) {
|
||||
items.push(restored);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
switch (record.type) {
|
||||
case 'user': {
|
||||
// Flush any pending tool group before user message
|
||||
if (currentToolGroup.length > 0) {
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: [...currentToolGroup],
|
||||
});
|
||||
currentToolGroup = [];
|
||||
}
|
||||
|
||||
const text = extractTextFromParts(record.message?.parts as Part[]);
|
||||
if (text) {
|
||||
items.push({ type: 'user', text });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'assistant': {
|
||||
const parts = record.message?.parts as Part[] | undefined;
|
||||
|
||||
// Extract text content (non-function-call, non-thought)
|
||||
const text = extractTextFromParts(parts);
|
||||
|
||||
// Extract function calls
|
||||
const functionCalls = extractFunctionCalls(parts);
|
||||
|
||||
// If there's text content, add it as a gemini message
|
||||
if (text) {
|
||||
// Flush any pending tool group before text
|
||||
if (currentToolGroup.length > 0) {
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: [...currentToolGroup],
|
||||
});
|
||||
currentToolGroup = [];
|
||||
}
|
||||
items.push({ type: 'gemini', text });
|
||||
}
|
||||
|
||||
// Track function calls for pairing with results
|
||||
for (const fc of functionCalls) {
|
||||
const tool = getTool(config, fc.name);
|
||||
|
||||
pendingToolCalls.set(fc.id, { name: fc.name, args: fc.args });
|
||||
|
||||
// Add placeholder tool call to current group
|
||||
currentToolGroup.push({
|
||||
callId: fc.id,
|
||||
name: tool?.displayName || fc.name,
|
||||
description: tool ? formatToolDescription(tool, fc.args) : '',
|
||||
resultDisplay: undefined,
|
||||
status: ToolCallStatus.Success, // Will be updated by tool_result
|
||||
confirmationDetails: undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_result': {
|
||||
// Update the corresponding tool call in the current group
|
||||
if (record.toolCallResult) {
|
||||
const callId = record.toolCallResult.callId;
|
||||
const toolCall = currentToolGroup.find((t) => t.callId === callId);
|
||||
if (toolCall) {
|
||||
// Preserve the resultDisplay as-is - it can be a string or structured object
|
||||
const rawDisplay = record.toolCallResult.resultDisplay;
|
||||
toolCall.resultDisplay = rawDisplay;
|
||||
// Check if status exists and use it
|
||||
const rawStatus = (
|
||||
record.toolCallResult as Record<string, unknown>
|
||||
)['status'] as string | undefined;
|
||||
toolCall.status =
|
||||
rawStatus === 'error'
|
||||
? ToolCallStatus.Error
|
||||
: ToolCallStatus.Success;
|
||||
}
|
||||
pendingToolCalls.delete(callId || '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// Skip unknown record types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining tool group
|
||||
if (currentToolGroup.length > 0) {
|
||||
items.push({
|
||||
type: 'tool_group',
|
||||
tools: currentToolGroup,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the complete UI history items for a resumed session.
|
||||
*
|
||||
* This function takes the resumed session data, converts it to UI history format,
|
||||
* and assigns unique IDs to each item for use with loadHistory.
|
||||
*
|
||||
* @param sessionData The resumed session data from SessionService
|
||||
* @param config The config object for accessing tool registry
|
||||
* @param baseTimestamp Base timestamp for generating unique IDs
|
||||
* @returns Array of HistoryItem with proper IDs
|
||||
*/
|
||||
export function buildResumedHistoryItems(
|
||||
sessionData: ResumedSessionData,
|
||||
config: Config,
|
||||
baseTimestamp: number = Date.now(),
|
||||
): HistoryItem[] {
|
||||
const items: HistoryItem[] = [];
|
||||
let idCounter = 1;
|
||||
|
||||
const getNextId = (): number => baseTimestamp + idCounter++;
|
||||
|
||||
// Convert conversation directly to history items
|
||||
const historyItems = convertToHistoryItems(sessionData.conversation, config);
|
||||
for (const item of historyItems) {
|
||||
items.push({
|
||||
...item,
|
||||
id: getNextId(),
|
||||
} as HistoryItem);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
Reference in New Issue
Block a user