Merge branch 'main' into feat/support-permission

This commit is contained in:
LaZzyMan 2026-03-19 19:08:55 +08:00
commit b59864f554
57 changed files with 14025 additions and 619 deletions

View file

@ -1461,6 +1461,109 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
Notification: {
type: 'array',
label: 'Notification Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when notifications are sent.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PreToolUse: {
type: 'array',
label: 'Pre Tool Use Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute before tool execution.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PostToolUse: {
type: 'array',
label: 'Post Tool Use Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute after successful tool execution.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PostToolUseFailure: {
type: 'array',
label: 'Post Tool Use Failure Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when tool execution fails. ',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SessionStart: {
type: 'array',
label: 'Session Start Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when a new session starts or resumes.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SessionEnd: {
type: 'array',
label: 'Session End Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when a session ends.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PreCompact: {
type: 'array',
label: 'Pre Compact Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute before conversation compaction.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SubagentStart: {
type: 'array',
label: 'Subagent Start Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when a subagent (Task tool call) is started.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SubagentStop: {
type: 'array',
label: 'Subagent Stop Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute right before a subagent (Task tool call) concludes its response.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PermissionRequest: {
type: 'array',
label: 'Permission Request Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when a permission dialog is displayed.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
},
},

View file

@ -97,7 +97,7 @@ export function generateCodingPlanTemplate(
extra_body: {
enable_thinking: true,
},
contextWindowSize: 1000000,
contextWindowSize: 196608,
},
},
{
@ -222,7 +222,7 @@ export function generateCodingPlanTemplate(
extra_body: {
enable_thinking: true,
},
contextWindowSize: 1000000,
contextWindowSize: 196608,
},
},
{

View file

@ -58,6 +58,17 @@ vi.mock('../ui/commands/permissionsCommand.js', async () => {
};
});
vi.mock('../ui/commands/hooksCommand.js', async () => {
const { CommandKind } = await import('../ui/commands/types.js');
return {
hooksCommand: {
name: 'hooks',
description: 'Hooks command',
kind: CommandKind.BUILT_IN,
},
};
});
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
import type { Config } from '@qwen-code/qwen-code-core';
@ -110,6 +121,7 @@ describe('BuiltinCommandLoader', () => {
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
getUseModelRouter: () => false,
getEnableHooks: vi.fn().mockReturnValue(true),
} as unknown as Config;
restoreCommandMock.mockReturnValue({
@ -194,4 +206,19 @@ describe('BuiltinCommandLoader', () => {
expect(modelCmd).toBeDefined();
expect(modelCmd?.name).toBe('model');
});
it('should include hooks command when enableHooks is true', async () => {
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const hooksCmd = commands.find((c) => c.name === 'hooks');
expect(hooksCmd).toBeDefined();
});
it('should exclude hooks command when enableHooks is false', async () => {
(mockConfig.getEnableHooks as Mock).mockReturnValue(false);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const hooksCmd = commands.find((c) => c.name === 'hooks');
expect(hooksCmd).toBeUndefined();
});
});

View file

@ -78,7 +78,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
exportCommand,
extensionsCommand,
helpCommand,
hooksCommand,
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
await ideCommand(),
initCommand,
languageCommand,

View file

@ -39,6 +39,8 @@ import {
getAllGeminiMdFilenames,
ShellExecutionService,
Storage,
SessionEndReason,
SessionStartSource,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { validateAuthMethod } from '../config/auth.js';
@ -295,7 +297,42 @@ export const AppContainer = (props: AppContainerProps) => {
);
historyManager.loadHistory(historyItems);
}
// Fire SessionStart event after config is initialized
const sessionStartSource = resumedSessionData
? SessionStartSource.Resume
: SessionStartSource.Startup;
const hookSystem = config.getHookSystem();
if (hookSystem) {
hookSystem
.fireSessionStartEvent(sessionStartSource, config.getModel() ?? '')
.then(() => {
debugLogger.debug('SessionStart event completed successfully');
})
.catch((err) => {
debugLogger.warn(`SessionStart hook failed: ${err}`);
});
} else {
debugLogger.debug(
'SessionStart: HookSystem not available, skipping event',
);
}
})();
// Register SessionEnd cleanup for process exit
registerCleanup(async () => {
try {
await config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.PromptInputExit);
debugLogger.debug('SessionEnd event completed successfully!!!');
} catch (err) {
debugLogger.error(`SessionEnd hook failed: ${err}`);
}
});
registerCleanup(async () => {
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
@ -1077,6 +1114,7 @@ export const AppContainer = (props: AppContainerProps) => {
streamingState,
elapsedTime,
settings,
config,
});
// Dialog close functionality

View file

@ -8,6 +8,10 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
import { clearCommand } from './clearCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import {
SessionEndReason,
SessionStartSource,
} from '@qwen-code/qwen-code-core';
// Mock the telemetry service
vi.mock('@qwen-code/qwen-code-core', async () => {
@ -26,10 +30,19 @@ describe('clearCommand', () => {
let mockContext: CommandContext;
let mockResetChat: ReturnType<typeof vi.fn>;
let mockStartNewSession: ReturnType<typeof vi.fn>;
let mockFireSessionEndEvent: ReturnType<typeof vi.fn>;
let mockFireSessionStartEvent: ReturnType<typeof vi.fn>;
let mockGetHookSystem: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockResetChat = vi.fn().mockResolvedValue(undefined);
mockStartNewSession = vi.fn().mockReturnValue('new-session-id');
mockFireSessionEndEvent = vi.fn().mockResolvedValue(undefined);
mockFireSessionStartEvent = vi.fn().mockResolvedValue(undefined);
mockGetHookSystem = vi.fn().mockReturnValue({
fireSessionEndEvent: mockFireSessionEndEvent,
fireSessionStartEvent: mockFireSessionStartEvent,
});
vi.clearAllMocks();
mockContext = createMockCommandContext({
@ -40,6 +53,11 @@ describe('clearCommand', () => {
resetChat: mockResetChat,
}) as unknown as GeminiClient,
startNewSession: mockStartNewSession,
getHookSystem: mockGetHookSystem,
getDebugLogger: () => ({
warn: vi.fn(),
}),
getModel: () => 'test-model',
getToolRegistry: () => undefined,
},
},
@ -76,6 +94,50 @@ describe('clearCommand', () => {
expect(mockContext.ui.clear).toHaveBeenCalled();
});
it('should fire SessionEnd event before clearing and SessionStart event after clearing', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
await clearCommand.action(mockContext, '');
expect(mockGetHookSystem).toHaveBeenCalled();
expect(mockFireSessionEndEvent).toHaveBeenCalledWith(
SessionEndReason.Clear,
);
expect(mockFireSessionStartEvent).toHaveBeenCalledWith(
SessionStartSource.Clear,
'test-model',
);
// SessionEnd should be called before SessionStart
const sessionEndCallOrder =
mockFireSessionEndEvent.mock.invocationCallOrder[0];
const sessionStartCallOrder =
mockFireSessionStartEvent.mock.invocationCallOrder[0];
expect(sessionEndCallOrder).toBeLessThan(sessionStartCallOrder);
});
it('should handle hook errors gracefully and continue execution', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
mockFireSessionEndEvent.mockRejectedValue(
new Error('SessionEnd hook failed'),
);
mockFireSessionStartEvent.mockRejectedValue(
new Error('SessionStart hook failed'),
);
await clearCommand.action(mockContext, '');
// Should still complete the clear operation despite hook errors
expect(mockStartNewSession).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
});
it('should not attempt to reset chat if config service is not available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');

View file

@ -9,6 +9,8 @@ import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import {
uiTelemetryService,
SessionEndReason,
SessionStartSource,
ToolNames,
SkillTool,
} from '@qwen-code/qwen-code-core';
@ -24,6 +26,15 @@ export const clearCommand: SlashCommand = {
const { config } = context.services;
if (config) {
// Fire SessionEnd event before clearing (current session ends)
try {
await config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.Clear);
} catch (err) {
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
}
const newSessionId = config.startNewSession();
// Reset UI telemetry metrics for the new session
@ -53,6 +64,18 @@ export const clearCommand: SlashCommand = {
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}
// Fire SessionStart event after clearing (new session starts)
try {
await config
.getHookSystem()
?.fireSessionStartEvent(
SessionStartSource.Clear,
config.getModel() ?? '',
);
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
}
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}

View file

@ -11,6 +11,11 @@ import {
AttentionNotificationReason,
} from '../../utils/attentionNotification.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
fireNotificationHook,
NotificationType,
} from '@qwen-code/qwen-code-core';
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
@ -19,6 +24,7 @@ interface UseAttentionNotificationsOptions {
streamingState: StreamingState;
elapsedTime: number;
settings: LoadedSettings;
config?: Config;
}
export const useAttentionNotifications = ({
@ -26,10 +32,12 @@ export const useAttentionNotifications = ({
streamingState,
elapsedTime,
settings,
config,
}: UseAttentionNotificationsOptions) => {
const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true;
const awaitingNotificationSentRef = useRef(false);
const respondingElapsedRef = useRef(0);
const idleNotificationSentRef = useRef(false);
useEffect(() => {
if (
@ -51,6 +59,8 @@ export const useAttentionNotifications = ({
useEffect(() => {
if (streamingState === StreamingState.Responding) {
respondingElapsedRef.current = elapsedTime;
// Reset idle notification flag when responding
idleNotificationSentRef.current = false;
return;
}
@ -65,7 +75,28 @@ export const useAttentionNotifications = ({
}
// Reset tracking for next task
respondingElapsedRef.current = 0;
// Fire idle_prompt notification hook when entering idle state
if (config && !idleNotificationSentRef.current) {
const messageBus = config.getMessageBus();
const hooksEnabled = config.getEnableHooks();
if (hooksEnabled && messageBus) {
fireNotificationHook(
messageBus,
'Qwen Code is waiting for your input',
NotificationType.IdlePrompt,
'Waiting for input',
).catch(() => {
// Silently ignore errors - fireNotificationHook has internal error handling
// and notification hooks should not block the idle flow
});
}
idleNotificationSentRef.current = true;
}
return;
}
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
// Reset idle notification flag when in WaitingForConfirmation state
idleNotificationSentRef.current = false;
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled, config]);
};

View file

@ -142,6 +142,11 @@ describe('useResumeCommand', () => {
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
getDebugLogger: () => ({
warn: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
} as unknown as import('@qwen-code/qwen-code-core').Config;
const { result } = renderHook(() =>

View file

@ -5,7 +5,11 @@
*/
import { useState, useCallback } from 'react';
import { SessionService, type Config } from '@qwen-code/qwen-code-core';
import {
SessionService,
type Config,
SessionStartSource,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@ -67,6 +71,18 @@ export function useResumeCommand(
config.startNewSession(sessionId, sessionData);
await config.getGeminiClient()?.initialize?.();
// Fire SessionStart event after resuming session
try {
await config
.getHookSystem()
?.fireSessionStartEvent(
SessionStartSource.Resume,
config.getModel() ?? '',
);
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
}
// Refresh terminal UI.
remount?.();
},

View file

@ -68,6 +68,15 @@ const mockConfig = {
getGeminiClient: () => null, // No client needed for these tests
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
getChatRecordingService: () => undefined,
getMessageBus: vi.fn().mockReturnValue(undefined),
getEnableHooks: vi.fn().mockReturnValue(false),
getHookSystem: vi.fn().mockReturnValue(undefined),
getDebugLogger: vi.fn().mockReturnValue({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
} as unknown as Config;
const mockTool = new MockTool({

View file

@ -6,10 +6,395 @@
import { randomUUID } from 'node:crypto';
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { SessionContext } from '../../../acp-integration/session/types.js';
import type { SessionUpdate, ToolCall } from '@agentclientprotocol/sdk';
import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js';
import type { ExportMessage, ExportSessionData } from './types.js';
import type {
ExportMessage,
ExportSessionData,
ExportMetadata,
} from './types.js';
/**
* File operation statistics extracted from tool calls.
*/
interface FileOperationStats {
filesWritten: number;
linesAdded: number;
linesRemoved: number;
writtenFilePaths: Set<string>;
}
/**
* Tool call arguments index for matching tool_result records.
*/
interface ToolCallArgsIndex {
byId: Map<string, Record<string, unknown>>;
byName: Map<string, Array<Record<string, unknown>>>;
}
/**
* Extracts tool name from a ChatRecord's function response.
*/
function extractToolNameFromRecord(record: ChatRecord): string | undefined {
if (!record.message?.parts) {
return undefined;
}
for (const part of record.message.parts) {
if ('functionResponse' in part && part.functionResponse?.name) {
return part.functionResponse.name;
}
}
return undefined;
}
/**
* Extracts call ID from a ChatRecord's function response.
*/
function extractFunctionResponseId(record: ChatRecord): string | undefined {
if (!record.message?.parts) {
return undefined;
}
for (const part of record.message.parts) {
if ('functionResponse' in part && part.functionResponse?.id) {
return part.functionResponse.id;
}
}
return undefined;
}
/**
* Normalizes function call args into a plain object.
*/
function normalizeFunctionCallArgs(
args: unknown,
): Record<string, unknown> | undefined {
if (args && typeof args === 'object') {
return args as Record<string, unknown>;
}
if (typeof args === 'string') {
try {
const parsed = JSON.parse(args) as unknown;
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore parse errors and treat as unavailable args
}
}
return undefined;
}
/**
* Builds an index of assistant tool calls for later tool_result arg resolution.
*/
function buildToolCallArgsIndex(records: ChatRecord[]): ToolCallArgsIndex {
const byId = new Map<string, Record<string, unknown>>();
const byName = new Map<string, Array<Record<string, unknown>>>();
for (const record of records) {
if (record.type !== 'assistant' || !record.message?.parts) continue;
for (const part of record.message.parts) {
if (!('functionCall' in part) || !part.functionCall?.name) continue;
const normalizedArgs = normalizeFunctionCallArgs(part.functionCall.args);
if (!normalizedArgs) continue;
const toolName = part.functionCall.name;
const callId =
typeof part.functionCall.id === 'string' ? part.functionCall.id : null;
if (callId) {
byId.set(callId, normalizedArgs);
}
const queue = byName.get(toolName) ?? [];
queue.push(normalizedArgs);
byName.set(toolName, queue);
}
}
return { byId, byName };
}
/**
* Calculate file operation statistics from ChatRecords.
* Uses toolCallResult from tool_result records for accurate statistics.
*/
function calculateFileStats(records: ChatRecord[]): FileOperationStats {
const argsIndex = buildToolCallArgsIndex(records);
const byNameCursor = new Map<string, number>();
const stats: FileOperationStats = {
filesWritten: 0,
linesAdded: 0,
linesRemoved: 0,
writtenFilePaths: new Set(),
};
for (const record of records) {
if (record.type !== 'tool_result' || !record.toolCallResult) continue;
const toolName = extractToolNameFromRecord(record);
const callId =
record.toolCallResult.callId ?? extractFunctionResponseId(record);
const argsFromId =
callId && argsIndex.byId.has(callId)
? argsIndex.byId.get(callId)
: undefined;
let args = argsFromId;
if (!args && toolName) {
const queue = argsIndex.byName.get(toolName);
if (queue && queue.length > 0) {
const cursor = byNameCursor.get(toolName) ?? 0;
args = queue[cursor];
byNameCursor.set(toolName, cursor + 1);
}
}
const { resultDisplay } = record.toolCallResult;
// Track file locations from resultDisplay
if (
resultDisplay &&
typeof resultDisplay === 'object' &&
'fileName' in resultDisplay
) {
const display = resultDisplay as {
fileName: string;
fileDiff?: string;
originalContent?: string | null;
newContent?: string;
diffStat?: { model_added_lines?: number; model_removed_lines?: number };
};
// Determine operation type based on content fields
const hasOriginalContent = 'originalContent' in display;
const hasNewContent = 'newContent' in display;
// For write/edit operations, use full path from args if available
let filePath: string;
if (typeof display.fileName === 'string') {
// Prefer args.file_path for full path, fallback to fileName (which may be basename)
filePath =
(args?.['file_path'] as string) ||
(args?.['absolute_path'] as string) ||
display.fileName;
} else {
// Fallback if fileName is not a string
filePath = 'unknown';
}
if (hasOriginalContent || hasNewContent) {
// This is a write/edit operation
stats.filesWritten++;
stats.writtenFilePaths.add(filePath);
// Calculate line changes
if (display.diffStat) {
// Use diffStat if available for accurate counts
stats.linesAdded += display.diffStat.model_added_lines ?? 0;
stats.linesRemoved += display.diffStat.model_removed_lines ?? 0;
} else {
// Fallback: count lines in content
const oldText = String(display.originalContent ?? '');
const newText = String(display.newContent ?? '');
// Count non-empty lines
const oldLines = oldText
.split('\n')
.filter((line) => line.length > 0).length;
const newLines = newText
.split('\n')
.filter((line) => line.length > 0).length;
stats.linesAdded += newLines;
stats.linesRemoved += oldLines;
}
}
}
}
return stats;
}
/**
* Extracts token usage from TaskResultDisplay executionSummary.
*/
function extractTaskToolTokens(record: ChatRecord): number {
if (record.type !== 'tool_result' || !record.toolCallResult?.resultDisplay) {
return 0;
}
const { resultDisplay } = record.toolCallResult;
if (
typeof resultDisplay === 'object' &&
'type' in resultDisplay &&
resultDisplay.type === 'task_execution' &&
'executionSummary' in resultDisplay
) {
const summary = resultDisplay.executionSummary as {
totalTokens?: number;
inputTokens?: number;
outputTokens?: number;
thoughtTokens?: number;
cachedTokens?: number;
};
// Use totalTokens if available, otherwise sum individual token counts
if (typeof summary.totalTokens === 'number') {
return summary.totalTokens;
}
// Fallback: sum available token counts
return (
(summary.inputTokens ?? 0) +
(summary.outputTokens ?? 0) +
(summary.thoughtTokens ?? 0) +
(summary.cachedTokens ?? 0)
);
}
return 0;
}
/**
* Calculate token statistics from ChatRecords.
* Aggregates usageMetadata from assistant records and TaskTool executionSummary to get total token usage.
* Uses the last assistant record that has both totalTokenCount and contextWindowSize for calculating context usage percent.
*/
function calculateTokenStats(records: ChatRecord[]): {
totalTokens: number;
contextUsagePercent?: number;
contextWindowSize?: number;
} {
let totalTokens = 0;
// Track the last assistant record that has BOTH totalTokenCount and contextWindowSize
// to ensure the percentage calculation uses values from the same record
let lastValidRecord: {
totalTokenCount: number;
contextWindowSize: number;
} | null = null;
// Aggregate usageMetadata from all assistant records
for (const record of records) {
if (record.type === 'assistant') {
if (record.usageMetadata) {
totalTokens += record.usageMetadata.totalTokenCount ?? 0;
}
// Only update lastValidRecord when BOTH values are present in the same record
if (
record.usageMetadata?.totalTokenCount !== undefined &&
record.contextWindowSize !== undefined
) {
lastValidRecord = {
totalTokenCount: record.usageMetadata.totalTokenCount,
contextWindowSize: record.contextWindowSize,
};
}
}
// Include TaskTool token usage from executionSummary
const taskTokens = extractTaskToolTokens(record);
if (taskTokens > 0) {
totalTokens += taskTokens;
}
}
// Use last valid record's values for context usage calculation
// This represents how much of the context window is being used by the total tokens
if (lastValidRecord) {
const percent =
(lastValidRecord.totalTokenCount / lastValidRecord.contextWindowSize) *
100;
return {
totalTokens,
contextUsagePercent: Math.round(percent * 10) / 10,
contextWindowSize: lastValidRecord.contextWindowSize,
};
}
// Fallback: return the contextWindowSize from the last assistant record even if no valid pair found
// (for display purposes only, without percentage)
const lastAssistantRecord = [...records]
.reverse()
.find((r) => r.type === 'assistant' && r.contextWindowSize !== undefined);
return {
totalTokens,
contextWindowSize: lastAssistantRecord?.contextWindowSize,
};
}
/**
* Extract session metadata from ChatRecords.
*/
async function extractMetadata(
conversation: {
sessionId: string;
startTime: string;
messages: ChatRecord[];
},
config: Config,
): Promise<ExportMetadata> {
const { sessionId, startTime, messages } = conversation;
// Extract basic info from the first record
const firstRecord = messages[0];
const cwd = firstRecord?.cwd ?? '';
const gitBranch = firstRecord?.gitBranch;
// Get git repository name
let gitRepo: string | undefined;
if (cwd) {
const { getGitRepoName } = await import('@qwen-code/qwen-code-core');
gitRepo = getGitRepoName(cwd);
}
// Try to get model from assistant messages
let model: string | undefined;
for (const record of messages) {
if (record.type === 'assistant' && record.model) {
model = record.model;
break;
}
}
// Get channel from config
const channel = config.getChannel?.();
// Count user prompts
const promptCount = messages.filter((m) => m.type === 'user').length;
// Calculate file stats from original ChatRecords
const fileStats = calculateFileStats(messages);
// Calculate token stats from original ChatRecords
// contextWindowSize is retrieved from the last assistant record for accuracy
const tokenStats = calculateTokenStats(messages);
return {
sessionId,
startTime,
exportTime: new Date().toISOString(),
cwd,
gitRepo,
gitBranch,
model,
channel,
promptCount,
contextUsagePercent: tokenStats.contextUsagePercent,
contextWindowSize: tokenStats.contextWindowSize,
totalTokens: tokenStats.totalTokens,
filesWritten: fileStats.writtenFilePaths.size,
linesAdded: fileStats.linesAdded,
linesRemoved: fileStats.linesRemoved,
uniqueFiles: Array.from(fileStats.writtenFilePaths),
};
}
/**
* Export session context that captures session updates into export messages.
@ -24,6 +409,7 @@ class ExportSessionContext implements SessionContext {
role: 'user' | 'assistant' | 'thinking';
parts: Array<{ text: string }>;
timestamp: number;
usageMetadata?: GenerateContentResponseUsageMetadata;
} | null = null;
private activeRecordId: string | null = null;
private activeRecordTimestamp: string | null = null;
@ -39,9 +425,37 @@ class ExportSessionContext implements SessionContext {
case 'user_message_chunk':
this.handleMessageChunk('user', update.content);
break;
case 'agent_message_chunk':
this.handleMessageChunk('assistant', update.content);
case 'agent_message_chunk': {
// Extract usageMetadata from _meta if available
const usageMeta = update._meta as
| {
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
thoughtTokens?: number;
cachedReadTokens?: number;
};
}
| undefined;
const usageMetadata: GenerateContentResponseUsageMetadata | undefined =
usageMeta?.usage
? {
promptTokenCount: usageMeta.usage.inputTokens,
candidatesTokenCount: usageMeta.usage.outputTokens,
totalTokenCount: usageMeta.usage.totalTokens,
thoughtsTokenCount: usageMeta.usage.thoughtTokens,
cachedContentTokenCount: usageMeta.usage.cachedReadTokens,
}
: undefined;
this.handleMessageChunk(
'assistant',
update.content,
'assistant',
usageMetadata,
);
break;
}
case 'agent_thought_chunk':
this.handleMessageChunk('assistant', update.content, 'thinking');
break;
@ -79,6 +493,7 @@ class ExportSessionContext implements SessionContext {
role: 'user' | 'assistant',
content: { type: string; text?: string },
messageRole: 'user' | 'assistant' | 'thinking' = role,
usageMetadata?: GenerateContentResponseUsageMetadata,
): void {
if (content.type !== 'text' || !content.text) return;
@ -98,12 +513,17 @@ class ExportSessionContext implements SessionContext {
this.currentMessage.role === messageRole
) {
this.currentMessage.parts.push({ text: content.text });
// Merge usageMetadata if provided (for assistant messages)
if (usageMetadata && role === 'assistant') {
this.currentMessage.usageMetadata = usageMetadata;
}
} else {
this.currentMessage = {
type: role,
role: messageRole,
parts: [{ text: content.text }],
timestamp: Date.now(),
...(usageMetadata && role === 'assistant' ? { usageMetadata } : {}),
};
}
}
@ -205,7 +625,7 @@ class ExportSessionContext implements SessionContext {
if (!this.currentMessage) return;
const uuid = this.getMessageUuid();
this.messages.push({
const exportMessage: ExportMessage = {
uuid,
sessionId: this.sessionId,
timestamp: this.getMessageTimestamp(),
@ -214,7 +634,17 @@ class ExportSessionContext implements SessionContext {
role: this.currentMessage.role,
parts: this.currentMessage.parts,
},
});
};
// Add usageMetadata for assistant messages
if (
this.currentMessage.type === 'assistant' &&
this.currentMessage.usageMetadata
) {
exportMessage.usageMetadata = this.currentMessage.usageMetadata;
}
this.messages.push(exportMessage);
this.currentMessage = null;
}
@ -258,9 +688,13 @@ export async function collectSessionData(
// Get the export messages
const messages = exportContext.getMessages();
// Extract metadata from conversation
const metadata = await extractMetadata(conversation, config);
return {
sessionId: conversation.sessionId,
startTime: conversation.startTime,
messages,
metadata,
};
}

View file

@ -36,6 +36,7 @@ export function injectDataIntoHtmlTemplate(
sessionId: string;
startTime: string;
messages: unknown[];
metadata?: unknown;
},
): string {
const jsonData = JSON.stringify(data, null, 2);

View file

@ -12,15 +12,60 @@ import type { ExportSessionData } from '../types.js';
*/
export function toJsonl(sessionData: ExportSessionData): string {
const lines: string[] = [];
const sourceMetadata = sessionData.metadata;
// Add session metadata as the first line
lines.push(
JSON.stringify({
type: 'session_metadata',
sessionId: sessionData.sessionId,
startTime: sessionData.startTime,
}),
);
const metadata: Record<string, unknown> = {
type: 'session_metadata',
sessionId: sessionData.sessionId,
startTime: sessionData.startTime,
};
// Add all metadata fields if available
if (sourceMetadata?.exportTime) {
metadata['exportTime'] = sourceMetadata.exportTime;
}
if (sourceMetadata?.cwd) {
metadata['cwd'] = sourceMetadata.cwd;
}
if (sourceMetadata?.gitRepo) {
metadata['gitRepo'] = sourceMetadata.gitRepo;
}
if (sourceMetadata?.gitBranch) {
metadata['gitBranch'] = sourceMetadata.gitBranch;
}
if (sourceMetadata?.model) {
metadata['model'] = sourceMetadata.model;
}
if (sourceMetadata?.channel) {
metadata['channel'] = sourceMetadata.channel;
}
if (sourceMetadata?.promptCount !== undefined) {
metadata['promptCount'] = sourceMetadata.promptCount;
}
if (sourceMetadata?.contextUsagePercent !== undefined) {
metadata['contextUsagePercent'] = sourceMetadata.contextUsagePercent;
}
if (sourceMetadata?.contextWindowSize !== undefined) {
metadata['contextWindowSize'] = sourceMetadata.contextWindowSize;
}
if (sourceMetadata?.totalTokens !== undefined) {
metadata['totalTokens'] = sourceMetadata.totalTokens;
}
if (sourceMetadata?.filesWritten !== undefined) {
metadata['filesWritten'] = sourceMetadata.filesWritten;
}
if (sourceMetadata?.linesAdded !== undefined) {
metadata['linesAdded'] = sourceMetadata.linesAdded;
}
if (sourceMetadata?.linesRemoved !== undefined) {
metadata['linesRemoved'] = sourceMetadata.linesRemoved;
}
if (sourceMetadata?.uniqueFiles && sourceMetadata.uniqueFiles.length > 0) {
metadata['uniqueFiles'] = sourceMetadata.uniqueFiles;
}
lines.push(JSON.stringify(metadata));
// Add each message as a separate line
for (const message of sessionData.messages) {

View file

@ -11,12 +11,82 @@ import type { ExportSessionData, ExportMessage } from '../types.js';
*/
export function toMarkdown(sessionData: ExportSessionData): string {
const lines: string[] = [];
const metadata = sessionData.metadata;
// Add header with metadata
lines.push('# Chat Session Export\n');
lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``);
lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`);
lines.push(`- **Exported**: ${new Date().toISOString()}`);
lines.push(
`- **Exported**: ${sanitizeText(metadata?.exportTime ?? new Date().toISOString())}`,
);
lines.push('');
// Add context info
if (metadata?.cwd) {
lines.push(`- **Working Directory**: \`${sanitizeText(metadata.cwd)}\``);
}
if (metadata?.gitRepo) {
lines.push(`- **Git Repository**: ${sanitizeText(metadata.gitRepo)}`);
}
if (metadata?.gitBranch) {
lines.push(`- **Git Branch**: \`${sanitizeText(metadata.gitBranch)}\``);
}
lines.push('');
// Add model info
if (metadata?.model) {
lines.push(`- **Model**: ${sanitizeText(metadata.model)}`);
}
if (metadata?.channel) {
lines.push(`- **Channel**: ${sanitizeText(metadata.channel)}`);
}
if (metadata?.promptCount !== undefined) {
lines.push(`- **Prompt Count**: ${metadata.promptCount}`);
}
lines.push('');
// Add token stats
if (metadata?.totalTokens !== undefined) {
lines.push(`- **Total Tokens**: ${metadata.totalTokens}`);
}
if (metadata?.contextWindowSize !== undefined) {
lines.push(`- **Context Window Size**: ${metadata.contextWindowSize}`);
}
if (metadata?.contextUsagePercent !== undefined) {
lines.push(`- **Context Usage**: ${metadata.contextUsagePercent}%`);
}
lines.push('');
// Add file operation stats
if (metadata?.filesWritten !== undefined) {
lines.push(`- **Files Written**: ${metadata.filesWritten}`);
}
if (metadata?.linesAdded !== undefined) {
lines.push(`- **Lines Added**: ${metadata.linesAdded}`);
}
if (metadata?.linesRemoved !== undefined) {
lines.push(`- **Lines Removed**: ${metadata.linesRemoved}`);
}
// Add unique files list if available
if (metadata?.uniqueFiles && metadata.uniqueFiles.length > 0) {
lines.push('');
lines.push('<details>');
lines.push(
`<summary><strong>Unique Files Referenced (${metadata.uniqueFiles.length})</strong></summary>`,
);
lines.push('');
for (const file of metadata.uniqueFiles) {
lines.push(`- \`${sanitizeText(file)}\``);
}
lines.push('</details>');
}
lines.push('\n---\n');
// Process each message

View file

@ -28,6 +28,14 @@ export function normalizeSessionData(
}
});
// Build index of assistant messages by uuid for usageMetadata merging
const assistantMessageIndexByUuid = new Map<string, number>();
normalized.forEach((message, index) => {
if (message.type === 'assistant') {
assistantMessageIndexByUuid.set(message.uuid, index);
}
});
// Merge tool result information into tool call messages
for (const record of originalRecords) {
if (record.type !== 'tool_result') continue;
@ -58,6 +66,20 @@ export function normalizeSessionData(
mergeToolCallData(existingMessage.toolCall, toolCallMessage.toolCall);
}
// Merge usageMetadata from assistant records
for (const record of originalRecords) {
if (record.type !== 'assistant') continue;
if (!record.usageMetadata) continue;
const existingIndex = assistantMessageIndexByUuid.get(record.uuid);
if (existingIndex !== undefined) {
// Only set if not already present from collect phase
if (!normalized[existingIndex].usageMetadata) {
normalized[existingIndex].usageMetadata = record.usageMetadata;
}
}
}
return {
...sessionData,
messages: normalized,

View file

@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
/**
* Universal export message format - SSOT for all export formats.
* This is format-agnostic and contains all information needed for any export type.
@ -25,6 +27,9 @@ export interface ExportMessage {
/** Model used for assistant messages */
model?: string;
/** Token usage for this message (mainly for assistant messages) */
usageMetadata?: GenerateContentResponseUsageMetadata;
/** For tool_call messages */
toolCall?: {
toolCallId: string;
@ -44,6 +49,44 @@ export interface ExportMessage {
};
}
/**
* Metadata for export session - contains aggregated statistics and session context.
*/
export interface ExportMetadata {
/** Session ID */
sessionId: string;
/** ISO timestamp when session started */
startTime: string;
/** Export timestamp */
exportTime: string;
/** Current working directory */
cwd: string;
/** Git repository name, if available */
gitRepo?: string;
/** Git branch name, if available */
gitBranch?: string;
/** Model used in the session */
model?: string;
/** Channel/source identifier */
channel?: string;
/** Number of user prompts in the session */
promptCount: number;
/** Context window utilization percentage (0-100) */
contextUsagePercent?: number;
/** Context window size in tokens (used for calculating percentage) */
contextWindowSize?: number;
/** Total tokens used (prompt + completion) */
totalTokens?: number;
/** Number of files written/edited */
filesWritten?: number;
/** Lines of code added */
linesAdded?: number;
/** Lines of code removed */
linesRemoved?: number;
/** Unique files referenced in the session (written files only) */
uniqueFiles: string[];
}
/**
* Complete export session data - the single source of truth.
*/
@ -51,4 +94,6 @@ export interface ExportSessionData {
sessionId: string;
startTime: string;
messages: ExportMessage[];
/** Session metadata and statistics */
metadata?: ExportMetadata;
}