fix(export): fix TodoWrite tool call display in HTML export

- Restore plan update handling in collect.ts, formatting todo data as markdown checklist
- Skip todo_write tool_result processing in normalize.ts to avoid duplicates
- Add getMessageTimestamp() and getMessageUuid() methods to maintain message order
- Fix version parsing to support @latest tags
- Fix Windows spawn EINVAL error (CVE-2024-27980)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mingholy.lmh 2026-02-03 18:01:41 +08:00
parent 8d8449079d
commit a364c8212e
8 changed files with 120 additions and 15 deletions

View file

@ -26,6 +26,7 @@ class ExportSessionContext implements SessionContext {
timestamp: number;
} | null = null;
private activeRecordId: string | null = null;
private activeRecordTimestamp: string | null = null;
private toolCallMap: Map<string, ExportMessage['toolCall']> = new Map();
constructor(sessionId: string, config: Config) {
@ -51,14 +52,23 @@ class ExportSessionContext implements SessionContext {
case 'tool_call_update':
this.handleToolCallUpdate(update);
break;
case 'plan':
this.flushCurrentMessage();
this.handlePlanUpdate(update);
break;
default:
// Ignore other update types
break;
}
}
setActiveRecordId(recordId: string | null): void {
setActiveRecordId(recordId: string | null, timestamp?: string): void {
this.activeRecordId = recordId;
this.activeRecordTimestamp = timestamp ?? null;
}
private getMessageTimestamp(): string {
return this.activeRecordTimestamp ?? new Date().toISOString();
}
private getMessageUuid(): string {
@ -117,7 +127,7 @@ class ExportSessionContext implements SessionContext {
this.messages.push({
uuid,
sessionId: this.sessionId,
timestamp: new Date(toolCall.timestamp || Date.now()).toISOString(),
timestamp: this.getMessageTimestamp(),
type: 'tool_call',
toolCall,
});
@ -140,6 +150,57 @@ class ExportSessionContext implements SessionContext {
}
}
private handlePlanUpdate(update: {
entries: Array<{
content: string;
status: 'pending' | 'in_progress' | 'completed';
priority?: string;
}>;
}): void {
// Create a tool_call message for plan updates (TodoWriteTool)
// This ensures todos appear at the correct position in the chat
const uuid = this.getMessageUuid();
const timestamp = this.getMessageTimestamp();
// Format entries as markdown checklist text for UpdatedPlanToolCall.parsePlanEntries
const todoText = update.entries
.map((entry) => {
const checkbox =
entry.status === 'completed'
? '[x]'
: entry.status === 'in_progress'
? '[-]'
: '[ ]';
return `- ${checkbox} ${entry.content}`;
})
.join('\n');
const todoContent = [
{
type: 'content' as const,
content: {
type: 'text',
text: todoText,
},
},
];
this.messages.push({
uuid,
sessionId: this.sessionId,
timestamp,
type: 'tool_call',
toolCall: {
toolCallId: uuid, // Use the same uuid as toolCallId for plan updates
kind: 'todowrite',
title: 'TodoWrite',
status: 'completed',
content: todoContent,
timestamp: Date.parse(timestamp),
},
});
}
private flushCurrentMessage(): void {
if (!this.currentMessage) return;
@ -147,7 +208,7 @@ class ExportSessionContext implements SessionContext {
this.messages.push({
uuid,
sessionId: this.sessionId,
timestamp: new Date(this.currentMessage.timestamp).toISOString(),
timestamp: this.getMessageTimestamp(),
type: this.currentMessage.type,
message: {
role: this.currentMessage.role,

File diff suppressed because one or more lines are too long

View file

@ -5,7 +5,7 @@
*/
import type { Part } from '@google/genai';
import { ExitPlanModeTool } from '@qwen-code/qwen-code-core';
import { ExitPlanModeTool, ToolNames } from '@qwen-code/qwen-code-core';
import type { ChatRecord, Config, Kind } from '@qwen-code/qwen-code-core';
import type { ExportMessage, ExportSessionData } from './types.js';
@ -103,15 +103,25 @@ function buildToolCallMessageFromResult(
config: Config,
): ExportMessage | null {
const toolCallResult = record.toolCallResult;
const toolCallId = toolCallResult?.callId ?? record.uuid;
const toolName = extractToolNameFromRecord(record);
// Skip todo_write tool - it's already handled by plan update in collect.ts
// This prevents duplicate todo messages in the export
if (toolName === ToolNames.TODO_WRITE) {
return null;
}
const toolCallId = toolCallResult?.callId ?? record.uuid;
const functionCallArgs = extractFunctionCallArgs(record);
const { kind, title, locations } = resolveToolMetadata(
config,
toolName,
(toolCallResult as { args?: Record<string, unknown> } | undefined)?.args,
functionCallArgs ??
(toolCallResult as { args?: Record<string, unknown> } | undefined)?.args,
);
const rawInput = normalizeRawInput(
(toolCallResult as { args?: unknown } | undefined)?.args,
functionCallArgs ??
(toolCallResult as { args?: unknown } | undefined)?.args,
);
const content =
@ -154,6 +164,25 @@ function extractToolNameFromRecord(record: ChatRecord): string {
return '';
}
/**
* Extracts function call args from a ChatRecord.
*/
function extractFunctionCallArgs(
record: ChatRecord,
): Record<string, unknown> | undefined {
if (!record.message?.parts) {
return undefined;
}
for (const part of record.message.parts) {
if ('functionCall' in part && part.functionCall?.args) {
return part.functionCall.args as Record<string, unknown>;
}
}
return undefined;
}
/**
* Resolves tool metadata (kind, title, locations) from tool registry.
*/
@ -197,6 +226,10 @@ function mapToolKind(kind: Kind | undefined, toolName?: string): string {
return 'switch_mode';
}
if (toolName && toolName === ToolNames.TODO_WRITE) {
return 'todowrite';
}
const allowedKinds = new Set<string>([
'read',
'edit',

View file

@ -9,5 +9,5 @@
*/
export function generateExportFilename(extension: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
return `export-${timestamp}.${extension}`;
return `qwen-code-export-${timestamp}.${extension}`;
}