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

@@ -4,10 +4,95 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { formatDuration, formatMemoryUsage } from './formatters.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
formatDuration,
formatMemoryUsage,
formatRelativeTime,
} from './formatters.js';
describe('formatters', () => {
describe('formatRelativeTime', () => {
const NOW = 1700000000000; // Fixed timestamp for testing
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('should return "just now" for timestamps less than a minute ago', () => {
expect(formatRelativeTime(NOW - 30 * 1000)).toBe('just now');
expect(formatRelativeTime(NOW - 59 * 1000)).toBe('just now');
});
it('should return "1 minute ago" for exactly one minute', () => {
expect(formatRelativeTime(NOW - 60 * 1000)).toBe('1 minute ago');
});
it('should return plural minutes for multiple minutes', () => {
expect(formatRelativeTime(NOW - 5 * 60 * 1000)).toBe('5 minutes ago');
expect(formatRelativeTime(NOW - 30 * 60 * 1000)).toBe('30 minutes ago');
});
it('should return "1 hour ago" for exactly one hour', () => {
expect(formatRelativeTime(NOW - 60 * 60 * 1000)).toBe('1 hour ago');
});
it('should return plural hours for multiple hours', () => {
expect(formatRelativeTime(NOW - 3 * 60 * 60 * 1000)).toBe('3 hours ago');
expect(formatRelativeTime(NOW - 23 * 60 * 60 * 1000)).toBe(
'23 hours ago',
);
});
it('should return "1 day ago" for exactly one day', () => {
expect(formatRelativeTime(NOW - 24 * 60 * 60 * 1000)).toBe('1 day ago');
});
it('should return plural days for multiple days', () => {
expect(formatRelativeTime(NOW - 3 * 24 * 60 * 60 * 1000)).toBe(
'3 days ago',
);
expect(formatRelativeTime(NOW - 6 * 24 * 60 * 60 * 1000)).toBe(
'6 days ago',
);
});
it('should return "1 week ago" for exactly one week', () => {
expect(formatRelativeTime(NOW - 7 * 24 * 60 * 60 * 1000)).toBe(
'1 week ago',
);
});
it('should return plural weeks for multiple weeks', () => {
expect(formatRelativeTime(NOW - 14 * 24 * 60 * 60 * 1000)).toBe(
'2 weeks ago',
);
expect(formatRelativeTime(NOW - 21 * 24 * 60 * 60 * 1000)).toBe(
'3 weeks ago',
);
});
it('should return "1 month ago" for exactly one month (30 days)', () => {
expect(formatRelativeTime(NOW - 30 * 24 * 60 * 60 * 1000)).toBe(
'1 month ago',
);
});
it('should return plural months for multiple months', () => {
expect(formatRelativeTime(NOW - 60 * 24 * 60 * 60 * 1000)).toBe(
'2 months ago',
);
expect(formatRelativeTime(NOW - 90 * 24 * 60 * 60 * 1000)).toBe(
'3 months ago',
);
});
});
describe('formatMemoryUsage', () => {
it('should format bytes into KB', () => {
expect(formatMemoryUsage(12345)).toBe('12.1 KB');

View File

@@ -21,6 +21,40 @@ export const formatMemoryUsage = (bytes: number): string => {
* @param milliseconds The duration in milliseconds.
* @returns A formatted string representing the duration.
*/
/**
* Formats a timestamp into a human-readable relative time string.
* @param timestamp The timestamp in milliseconds since epoch.
* @returns A formatted string like "just now", "5 minutes ago", "2 days ago".
*/
export const formatRelativeTime = (timestamp: number): string => {
const now = Date.now();
const diffMs = now - timestamp;
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
if (months > 0) {
return months === 1 ? '1 month ago' : `${months} months ago`;
}
if (weeks > 0) {
return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`;
}
if (days > 0) {
return days === 1 ? '1 day ago' : `${days} days ago`;
}
if (hours > 0) {
return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
}
if (minutes > 0) {
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
}
return 'just now';
};
export const formatDuration = (milliseconds: number): string => {
if (milliseconds <= 0) {
return '0s';

View File

@@ -0,0 +1,279 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { buildResumedHistoryItems } from './resumeHistoryUtils.js';
import { ToolCallStatus } from '../types.js';
import type {
AnyDeclarativeTool,
Config,
ConversationRecord,
ResumedSessionData,
} from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
const makeConfig = (tools: Record<string, AnyDeclarativeTool>) =>
({
getToolRegistry: () => ({
getTool: (name: string) => tools[name],
}),
}) as unknown as Config;
describe('resumeHistoryUtils', () => {
let mockTool: AnyDeclarativeTool;
beforeEach(() => {
const mockInvocation = {
getDescription: () => 'Mocked description',
};
mockTool = {
name: 'replace',
displayName: 'Replace',
description: 'Replace text',
build: vi.fn().mockReturnValue(mockInvocation),
} as unknown as AnyDeclarativeTool;
});
it('converts conversation into history items with incremental ids', () => {
const conversation = {
messages: [
{
type: 'user',
message: { parts: [{ text: 'Hello' } as Part] },
},
{
type: 'assistant',
message: {
parts: [
{ text: 'Hi there' } as Part,
{
functionCall: {
id: 'call-1',
name: 'replace',
args: { old: 'a', new: 'b' },
},
} as unknown as Part,
],
},
},
{
type: 'tool_result',
toolCallResult: {
callId: 'call-1',
resultDisplay: 'All set',
status: 'success',
},
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const baseTimestamp = 1_000;
const items = buildResumedHistoryItems(
session,
makeConfig({ replace: mockTool }),
baseTimestamp,
);
expect(items).toEqual([
{ id: baseTimestamp + 1, type: 'user', text: 'Hello' },
{ id: baseTimestamp + 2, type: 'gemini', text: 'Hi there' },
{
id: baseTimestamp + 3,
type: 'tool_group',
tools: [
{
callId: 'call-1',
name: 'Replace',
description: 'Mocked description',
resultDisplay: 'All set',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
},
]);
});
it('marks tool results as error, skips thought text, and falls back when tool is missing', () => {
const conversation = {
messages: [
{
type: 'assistant',
message: {
parts: [
{
text: 'should be skipped',
thought: { subject: 'hidden' },
} as unknown as Part,
{ text: 'visible text' } as Part,
{
functionCall: {
id: 'missing-call',
name: 'unknown_tool',
args: { foo: 'bar' },
},
} as unknown as Part,
],
},
},
{
type: 'tool_result',
toolCallResult: {
callId: 'missing-call',
resultDisplay: { summary: 'failure' },
status: 'error',
},
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const items = buildResumedHistoryItems(session, makeConfig({}));
expect(items).toEqual([
{ id: expect.any(Number), type: 'gemini', text: 'visible text' },
{
id: expect.any(Number),
type: 'tool_group',
tools: [
{
callId: 'missing-call',
name: 'unknown_tool',
description: '',
resultDisplay: { summary: 'failure' },
status: ToolCallStatus.Error,
confirmationDetails: undefined,
},
],
},
]);
});
it('flushes pending tool groups before subsequent user messages', () => {
const conversation = {
messages: [
{
type: 'assistant',
message: {
parts: [
{
functionCall: {
id: 'call-2',
name: 'replace',
args: { target: 'a' },
},
} as unknown as Part,
],
},
},
{
type: 'user',
message: { parts: [{ text: 'next user message' } as Part] },
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const items = buildResumedHistoryItems(
session,
makeConfig({ replace: mockTool }),
10,
);
expect(items[0]).toEqual({
id: 11,
type: 'tool_group',
tools: [
{
callId: 'call-2',
name: 'Replace',
description: 'Mocked description',
resultDisplay: undefined,
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
});
expect(items[1]).toEqual({
id: 12,
type: 'user',
text: 'next user message',
});
});
it('replays slash command history items (e.g., /about) on resume', () => {
const conversation = {
messages: [
{
type: 'system',
subtype: 'slash_command',
systemPayload: {
phase: 'invocation',
rawCommand: '/about',
},
},
{
type: 'system',
subtype: 'slash_command',
systemPayload: {
phase: 'result',
rawCommand: '/about',
outputHistoryItems: [
{
type: 'about',
systemInfo: {
cliVersion: '1.2.3',
osPlatform: 'darwin',
osArch: 'arm64',
osRelease: 'test',
nodeVersion: '20.x',
npmVersion: '10.x',
sandboxEnv: 'none',
modelVersion: 'qwen',
selectedAuthType: 'none',
ideClient: 'none',
sessionId: 'abc',
memoryUsage: '0 MB',
},
},
],
},
},
{
type: 'assistant',
message: { parts: [{ text: 'Follow-up' } as Part] },
},
],
} as unknown as ConversationRecord;
const session: ResumedSessionData = {
conversation,
} as ResumedSessionData;
const items = buildResumedHistoryItems(session, makeConfig({}), 5);
expect(items).toEqual([
{ id: 6, type: 'user', text: '/about' },
{
id: 7,
type: 'about',
systemInfo: expect.objectContaining({ cliVersion: '1.2.3' }),
},
{ id: 8, type: 'gemini', text: 'Follow-up' },
]);
});
});

View 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;
}