mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
Session-Level Conversation History Management (#1113)
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
279
packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
Normal file
279
packages/cli/src/ui/utils/resumeHistoryUtils.test.ts
Normal 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
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