mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(daemon): Add session export endpoint (#6297)
* feat(daemon): add session export endpoint Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address PR review feedback (#6297) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: fix PR integration capability baseline (#6297) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * codex: address export tool call id review (#6297) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
parent
0e684a3444
commit
59e771cef6
25 changed files with 1170 additions and 97 deletions
|
|
@ -277,6 +277,7 @@ describe('qwen serve — capabilities envelope', () => {
|
|||
'session_close',
|
||||
'session_archive',
|
||||
'session_metadata',
|
||||
'session_export',
|
||||
'mcp_guardrails',
|
||||
'workspace_mcp_manage',
|
||||
'mcp_guardrail_events',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
import type { SessionContext } from './types.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { getToolResultCallId } from '../../utils/chat-record-tool-call-id.js';
|
||||
|
||||
export const MISSING_TOOL_RESULT_MESSAGE =
|
||||
'Tool result missing from saved history; the previous run likely ended ' +
|
||||
|
|
@ -250,7 +251,7 @@ export class HistoryReplayer {
|
|||
}
|
||||
|
||||
const result = record.toolCallResult;
|
||||
const callId = this.getToolResultCallId(record);
|
||||
const callId = getToolResultCallId(record);
|
||||
this.pendingReplayToolCalls.delete(callId);
|
||||
|
||||
// Extract tool name from the function response in message if available
|
||||
|
|
@ -379,29 +380,6 @@ export class HistoryReplayer {
|
|||
return '';
|
||||
}
|
||||
|
||||
private getToolResultCallId(record: ChatRecord): string {
|
||||
const resultCallId = record.toolCallResult?.callId;
|
||||
if (typeof resultCallId === 'string' && resultCallId.length > 0) {
|
||||
return resultCallId;
|
||||
}
|
||||
return this.extractFunctionResponseIdFromRecord(record) ?? record.uuid;
|
||||
}
|
||||
|
||||
private extractFunctionResponseIdFromRecord(
|
||||
record: ChatRecord,
|
||||
): string | undefined {
|
||||
if (record.message?.parts) {
|
||||
for (const part of record.message.parts) {
|
||||
const id =
|
||||
'functionResponse' in part ? part.functionResponse?.id : undefined;
|
||||
if (typeof id === 'string' && id.length > 0) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private setActiveRecordId(recordId: string | null, timestamp?: string): void {
|
||||
const context = this.ctx as unknown as {
|
||||
setActiveRecordId?: (id: string | null, timestamp?: string) => void;
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export const SERVE_CAPABILITY_REGISTRY = {
|
|||
session_close: { since: 'v1' },
|
||||
session_archive: { since: 'v1' },
|
||||
session_metadata: { since: 'v1' },
|
||||
session_export: { since: 'v1' },
|
||||
// Daemon supports the MCP client guardrail surface: an in-process
|
||||
// counter exposed on `GET /workspace/mcp`, a `--mcp-client-budget=N`
|
||||
// flag with `--mcp-budget-mode={enforce, warn, off}`, and a
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ import {
|
|||
type SessionArchiveCoordinator,
|
||||
unarchiveDaemonSessions,
|
||||
} from '../server/session-archive.js';
|
||||
import {
|
||||
exportSessionTranscript,
|
||||
parseSessionExportFormat,
|
||||
sessionExportFormatValues,
|
||||
} from '../server/session-export.js';
|
||||
|
||||
interface RegisterSessionRoutesDeps {
|
||||
boundWorkspace: string;
|
||||
|
|
@ -428,6 +433,49 @@ export function registerSessionRoutes(
|
|||
}
|
||||
});
|
||||
|
||||
app.get('/session/:id/export', async (req, res) => {
|
||||
const sessionId = requireSessionId(req, res);
|
||||
if (sessionId === null) return;
|
||||
const rawFormat = req.query['format'];
|
||||
const format = parseSessionExportFormat(rawFormat);
|
||||
if (!format) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid export format',
|
||||
code: 'invalid_export_format',
|
||||
format: typeof rawFormat === 'string' ? rawFormat : String(rawFormat),
|
||||
allowedFormats: sessionExportFormatValues(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await archiveCoordinator.runSharedMany(
|
||||
[sessionId],
|
||||
async () => {
|
||||
await assertSessionLoadable(boundWorkspace, sessionId);
|
||||
return exportSessionTranscript({
|
||||
workspaceCwd: boundWorkspace,
|
||||
sessionId,
|
||||
format,
|
||||
config: { getChannel: () => 'daemon' },
|
||||
});
|
||||
},
|
||||
);
|
||||
const filename = result.filename.replace(/["\\\r\n]/g, '_');
|
||||
res
|
||||
.status(200)
|
||||
.set('Cache-Control', 'no-store')
|
||||
.set('X-Content-Type-Options', 'nosniff')
|
||||
.set('Content-Type', result.mimeType)
|
||||
.set('Content-Disposition', `attachment; filename="${filename}"`)
|
||||
.send(result.content);
|
||||
} catch (err) {
|
||||
sendBridgeError(res, err, {
|
||||
route: 'GET /session/:id/export',
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/session/:id/context', async (req, res) => {
|
||||
const sessionId = requireSessionId(req, res);
|
||||
if (sessionId === null) return;
|
||||
|
|
|
|||
|
|
@ -216,6 +216,7 @@ const EXPECTED_STAGE1_FEATURES = [
|
|||
'session_close',
|
||||
'session_archive',
|
||||
'session_metadata',
|
||||
'session_export',
|
||||
// Issue #4175 PR 14. Always-on. Daemon supports the MCP client
|
||||
// guardrail surface (`--mcp-client-budget`, `clientCount` /
|
||||
// `budgets[]` on `/workspace/mcp`, `disabledReason: 'budget'` on
|
||||
|
|
@ -9487,6 +9488,168 @@ describe('createServeApp', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /session/:id/export', () => {
|
||||
let previousRuntimeDir: string | undefined;
|
||||
let runtimeDir: string;
|
||||
let wsDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousRuntimeDir = process.env['QWEN_RUNTIME_DIR'];
|
||||
runtimeDir = await fsp.mkdtemp(
|
||||
path.join(os.tmpdir(), 'qwen-serve-session-export-'),
|
||||
);
|
||||
process.env['QWEN_RUNTIME_DIR'] = runtimeDir;
|
||||
wsDir = realpathSync(runtimeDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (previousRuntimeDir === undefined) {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
} else {
|
||||
process.env['QWEN_RUNTIME_DIR'] = previousRuntimeDir;
|
||||
}
|
||||
await fsp.rm(runtimeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeExportSession(
|
||||
sessionId: string,
|
||||
state: 'active' | 'archived' = 'active',
|
||||
): Promise<void> {
|
||||
const chatsDir = path.join(
|
||||
new Storage(wsDir).getProjectDir(),
|
||||
'chats',
|
||||
...(state === 'archived' ? ['archive'] : []),
|
||||
);
|
||||
await fsp.mkdir(chatsDir, { recursive: true });
|
||||
const records = [
|
||||
{
|
||||
uuid: `${sessionId}-user-1`,
|
||||
parentUuid: null,
|
||||
sessionId,
|
||||
timestamp: '2026-05-28T12:00:00.000Z',
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello export' }],
|
||||
},
|
||||
cwd: wsDir,
|
||||
},
|
||||
{
|
||||
uuid: `${sessionId}-assistant-1`,
|
||||
parentUuid: `${sessionId}-user-1`,
|
||||
sessionId,
|
||||
timestamp: '2026-05-28T12:00:01.000Z',
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'export response' }],
|
||||
},
|
||||
cwd: wsDir,
|
||||
model: 'qwen-test',
|
||||
},
|
||||
];
|
||||
const body = records.map((record) => JSON.stringify(record)).join('\n');
|
||||
await fsp.writeFile(path.join(chatsDir, `${sessionId}.jsonl`), body);
|
||||
}
|
||||
|
||||
function createExportApp() {
|
||||
return createServeApp({ ...baseOpts, workspace: wsDir }, undefined, {
|
||||
bridge: fakeBridge(),
|
||||
boundWorkspace: wsDir,
|
||||
});
|
||||
}
|
||||
|
||||
it('exports HTML by default as an attachment', async () => {
|
||||
const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
await writeExportSession(sid);
|
||||
const app = createExportApp();
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/session/${sid}/export`)
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/html');
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.headers['content-disposition']).toMatch(
|
||||
/^attachment; filename="qwen-code-export-.+\.html"$/,
|
||||
);
|
||||
expect(res.text).toContain('id="chat-data"');
|
||||
expect(res.text).toContain('hello export');
|
||||
expect(res.text).toContain('export response');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['md', 'text/markdown', '# Chat Session Export'],
|
||||
['json', 'application/json', '"sessionId":'],
|
||||
['jsonl', 'application/jsonl', '"type":"session_metadata"'],
|
||||
])('exports %s format', async (format, mimeType, marker) => {
|
||||
const sid = `55555555-bbbb-cccc-dddd-${format.padEnd(12, '0')}`;
|
||||
await writeExportSession(sid);
|
||||
const app = createExportApp();
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/session/${sid}/export?format=${format}`)
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain(mimeType);
|
||||
expect(res.headers['content-disposition']).toContain(`.${format}"`);
|
||||
expect(res.text).toContain(marker);
|
||||
expect(res.text).toContain('hello export');
|
||||
if (format === 'json') {
|
||||
expect(res.body.metadata.channel).toBe('daemon');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid export format', async () => {
|
||||
const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
await writeExportSession(sid);
|
||||
const app = createExportApp();
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/session/${sid}/export?format=pdf`)
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toMatchObject({
|
||||
code: 'invalid_export_format',
|
||||
format: 'pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 404 for missing sessions', async () => {
|
||||
const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
const app = createExportApp();
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/session/${sid}/export`)
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toMatchObject({
|
||||
sessionId: sid,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns session_archived for archived sessions', async () => {
|
||||
const sid = '55555555-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
await writeExportSession(sid, 'archived');
|
||||
const app = createExportApp();
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/session/${sid}/export`)
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toMatchObject({
|
||||
code: 'session_archived',
|
||||
sessionId: sid,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /sessions/delete', () => {
|
||||
let previousRuntimeDir: string | undefined;
|
||||
let runtimeDir: string;
|
||||
|
|
|
|||
101
packages/cli/src/serve/server/session-export.ts
Normal file
101
packages/cli/src/serve/server/session-export.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SessionService } from '@qwen-code/qwen-code-core';
|
||||
import { SessionNotFoundError } from '../acp-session-bridge.js';
|
||||
import {
|
||||
collectSessionData,
|
||||
generateExportFilename,
|
||||
normalizeSessionData,
|
||||
toHtml,
|
||||
toJson,
|
||||
toJsonl,
|
||||
toMarkdown,
|
||||
type ExportConfig,
|
||||
type ExportSessionData,
|
||||
} from '../../ui/utils/export/index.js';
|
||||
|
||||
const SESSION_EXPORT_FORMATS = ['html', 'md', 'json', 'jsonl'] as const;
|
||||
|
||||
export type SessionExportFormat = (typeof SESSION_EXPORT_FORMATS)[number];
|
||||
|
||||
interface ExportFormatDefinition {
|
||||
mimeType: string;
|
||||
render: (data: ExportSessionData) => string;
|
||||
}
|
||||
|
||||
const EXPORT_FORMATS: Record<SessionExportFormat, ExportFormatDefinition> = {
|
||||
html: {
|
||||
mimeType: 'text/html; charset=utf-8',
|
||||
render: toHtml,
|
||||
},
|
||||
md: {
|
||||
mimeType: 'text/markdown; charset=utf-8',
|
||||
render: toMarkdown,
|
||||
},
|
||||
json: {
|
||||
mimeType: 'application/json; charset=utf-8',
|
||||
render: toJson,
|
||||
},
|
||||
jsonl: {
|
||||
mimeType: 'application/jsonl; charset=utf-8',
|
||||
render: toJsonl,
|
||||
},
|
||||
};
|
||||
|
||||
export interface SessionExportResult {
|
||||
format: SessionExportFormat;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function parseSessionExportFormat(
|
||||
rawFormat: unknown,
|
||||
): SessionExportFormat | undefined {
|
||||
if (rawFormat === undefined) return 'html';
|
||||
if (typeof rawFormat !== 'string') return undefined;
|
||||
return SESSION_EXPORT_FORMATS.includes(rawFormat as SessionExportFormat)
|
||||
? (rawFormat as SessionExportFormat)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function sessionExportFormatValues(): SessionExportFormat[] {
|
||||
return [...SESSION_EXPORT_FORMATS];
|
||||
}
|
||||
|
||||
export async function exportSessionTranscript(params: {
|
||||
workspaceCwd: string;
|
||||
sessionId: string;
|
||||
format: SessionExportFormat;
|
||||
config?: ExportConfig;
|
||||
}): Promise<SessionExportResult> {
|
||||
const { workspaceCwd, sessionId, format } = params;
|
||||
const sessionData = await new SessionService(workspaceCwd).loadSession(
|
||||
sessionId,
|
||||
);
|
||||
if (!sessionData) {
|
||||
throw new SessionNotFoundError(sessionId);
|
||||
}
|
||||
|
||||
const exportConfig = params.config ?? {};
|
||||
const collected = await collectSessionData(
|
||||
sessionData.conversation,
|
||||
exportConfig,
|
||||
);
|
||||
const normalized = normalizeSessionData(
|
||||
collected,
|
||||
sessionData.conversation.messages,
|
||||
exportConfig,
|
||||
);
|
||||
const formatDefinition = EXPORT_FORMATS[format];
|
||||
return {
|
||||
format,
|
||||
filename: generateExportFilename(format),
|
||||
mimeType: formatDefinition.mimeType,
|
||||
content: formatDefinition.render(normalized),
|
||||
};
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ChatRecord, Config } from '@qwen-code/qwen-code-core';
|
||||
import { collectSessionData } from './collect.js';
|
||||
import type { ExportConfig } from './types.js';
|
||||
|
||||
describe('collectSessionData', () => {
|
||||
const config = {
|
||||
|
|
@ -86,4 +87,80 @@ describe('collectSessionData', () => {
|
|||
expect(data.metadata?.linesAdded).toBe(0);
|
||||
expect(data.metadata?.linesRemoved).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts the minimal daemon export config shape', async () => {
|
||||
const minimalConfig: ExportConfig = {
|
||||
getChannel: () => 'web-shell',
|
||||
};
|
||||
|
||||
const data = await collectSessionData(
|
||||
{
|
||||
sessionId: 'session-minimal',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
uuid: 'user-1',
|
||||
parentUuid: null,
|
||||
sessionId: 'session-minimal',
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
type: 'user',
|
||||
cwd: '',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
minimalConfig,
|
||||
);
|
||||
|
||||
expect(data.metadata?.channel).toBe('web-shell');
|
||||
expect(data.messages[0]?.message?.parts?.[0]?.text).toBe('hello');
|
||||
});
|
||||
|
||||
it('replays tool calls when daemon export config has no tool registry', async () => {
|
||||
const minimalConfig: ExportConfig = {};
|
||||
|
||||
const data = await collectSessionData(
|
||||
{
|
||||
sessionId: 'session-minimal-tool',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
uuid: 'assistant-tool-1',
|
||||
parentUuid: null,
|
||||
sessionId: 'session-minimal-tool',
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
type: 'assistant',
|
||||
cwd: '',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call-minimal',
|
||||
name: 'shell',
|
||||
args: { command: 'pwd' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
minimalConfig,
|
||||
);
|
||||
|
||||
const toolCall = data.messages.find(
|
||||
(message) => message.type === 'tool_call',
|
||||
);
|
||||
expect(toolCall?.toolCall).toMatchObject({
|
||||
toolCallId: 'call-minimal',
|
||||
title: 'shell',
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,15 +5,18 @@
|
|||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { ChatRecord, Config } 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 { getExplicitToolResultCallId } from '../../../utils/chat-record-tool-call-id.js';
|
||||
import type {
|
||||
ExportConfig,
|
||||
ExportMessage,
|
||||
ExportSessionData,
|
||||
ExportMetadata,
|
||||
ExportToolRegistry,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
|
|
@ -51,23 +54,6 @@ function extractToolNameFromRecord(record: ChatRecord): string | undefined {
|
|||
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.
|
||||
*/
|
||||
|
|
@ -142,8 +128,7 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats {
|
|||
if (record.type !== 'tool_result' || !record.toolCallResult) continue;
|
||||
|
||||
const toolName = extractToolNameFromRecord(record);
|
||||
const callId =
|
||||
record.toolCallResult.callId ?? extractFunctionResponseId(record);
|
||||
const callId = getExplicitToolResultCallId(record);
|
||||
const argsFromId =
|
||||
callId && argsIndex.byId.has(callId)
|
||||
? argsIndex.byId.get(callId)
|
||||
|
|
@ -327,6 +312,32 @@ function calculateTokenStats(records: ChatRecord[]): {
|
|||
};
|
||||
}
|
||||
|
||||
function createExportSessionConfig(config: ExportConfig): Config {
|
||||
const supportedConfig = {
|
||||
...config,
|
||||
getToolRegistry: () => {
|
||||
const registry = config.getToolRegistry?.();
|
||||
return {
|
||||
getTool: (toolName: string) => registry?.getTool?.(toolName) ?? null,
|
||||
} satisfies ExportToolRegistry;
|
||||
},
|
||||
};
|
||||
|
||||
return new Proxy(supportedConfig, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop in target) {
|
||||
return Reflect.get(target, prop, receiver);
|
||||
}
|
||||
if (typeof prop === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
throw new Error(
|
||||
`Export session replay config does not implement ${prop}`,
|
||||
);
|
||||
},
|
||||
}) as unknown as Config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract session metadata from ChatRecords.
|
||||
*/
|
||||
|
|
@ -336,7 +347,7 @@ async function extractMetadata(
|
|||
startTime: string;
|
||||
messages: ChatRecord[];
|
||||
},
|
||||
config: Config,
|
||||
config: ExportConfig,
|
||||
): Promise<ExportMetadata> {
|
||||
const { sessionId, startTime, messages } = conversation;
|
||||
|
||||
|
|
@ -413,9 +424,9 @@ class ExportSessionContext implements SessionContext {
|
|||
private activeRecordTimestamp: string | null = null;
|
||||
private toolCallMap: Map<string, ExportMessage['toolCall']> = new Map();
|
||||
|
||||
constructor(sessionId: string, config: Config) {
|
||||
constructor(sessionId: string, config: ExportConfig) {
|
||||
this.sessionId = sessionId;
|
||||
this.config = config;
|
||||
this.config = createExportSessionConfig(config);
|
||||
}
|
||||
|
||||
async sendUpdate(update: SessionUpdate): Promise<void> {
|
||||
|
|
@ -666,7 +677,7 @@ export async function collectSessionData(
|
|||
startTime: string;
|
||||
messages: ChatRecord[];
|
||||
},
|
||||
config: Config,
|
||||
config: ExportConfig,
|
||||
): Promise<ExportSessionData> {
|
||||
// Create export session context
|
||||
const exportContext = new ExportSessionContext(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export type { ExportMessage, ExportSessionData } from './types.js';
|
||||
export type {
|
||||
ExportConfig,
|
||||
ExportMessage,
|
||||
ExportSessionData,
|
||||
} from './types.js';
|
||||
export { collectSessionData } from './collect.js';
|
||||
export { normalizeSessionData } from './normalize.js';
|
||||
export { toMarkdown } from './formatters/markdown.js';
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ChatRecord, Config } from '@qwen-code/qwen-code-core';
|
||||
import { normalizeSessionData } from './normalize.js';
|
||||
import type { ExportConfig } from './types.js';
|
||||
|
||||
describe('normalizeSessionData', () => {
|
||||
const config = {
|
||||
|
|
@ -69,4 +70,104 @@ describe('normalizeSessionData', () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts the minimal daemon export config shape', () => {
|
||||
const minimalConfig: ExportConfig = {};
|
||||
const record: ChatRecord = {
|
||||
uuid: 'tool-1',
|
||||
parentUuid: null,
|
||||
sessionId: 'session-1',
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
type: 'tool_result',
|
||||
cwd: '',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call-1',
|
||||
name: 'read_file',
|
||||
response: { output: 'ok' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
toolCallResult: {
|
||||
callId: 'call-1',
|
||||
resultDisplay: 'read result',
|
||||
},
|
||||
};
|
||||
|
||||
const normalized = normalizeSessionData(
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
messages: [],
|
||||
},
|
||||
[record],
|
||||
minimalConfig,
|
||||
);
|
||||
|
||||
expect(normalized.messages[0].toolCall?.title).toBe('read_file');
|
||||
});
|
||||
|
||||
it('matches tool results by functionResponse id when callId is absent', () => {
|
||||
const record: ChatRecord = {
|
||||
uuid: 'tool-result-record',
|
||||
parentUuid: null,
|
||||
sessionId: 'session-1',
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
type: 'tool_result',
|
||||
cwd: '',
|
||||
version: '1.0.0',
|
||||
message: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'function-response-call-id',
|
||||
name: 'read_file',
|
||||
response: { output: 'read result' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
toolCallResult: {
|
||||
resultDisplay: 'read result',
|
||||
},
|
||||
};
|
||||
|
||||
const normalized = normalizeSessionData(
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
uuid: 'tool-call-record',
|
||||
sessionId: 'session-1',
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
toolCallId: 'function-response-call-id',
|
||||
kind: 'other',
|
||||
title: 'read_file',
|
||||
status: 'in_progress',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
[record],
|
||||
config,
|
||||
);
|
||||
|
||||
expect(normalized.messages).toHaveLength(1);
|
||||
expect(normalized.messages[0].toolCall?.status).toBe('completed');
|
||||
expect(normalized.messages[0].toolCall?.content).toEqual([
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: 'read result' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,9 +6,14 @@
|
|||
|
||||
import type { Part } from '@google/genai';
|
||||
import { ToolNames } from '@qwen-code/qwen-code-core';
|
||||
import type { ChatRecord, Config, Kind } from '@qwen-code/qwen-code-core';
|
||||
import type { ChatRecord, Kind } from '@qwen-code/qwen-code-core';
|
||||
import { buildTruncatedDiffPreviewText } from '../../../utils/truncatedDiffPreview.js';
|
||||
import type { ExportMessage, ExportSessionData } from './types.js';
|
||||
import { getToolResultCallId } from '../../../utils/chat-record-tool-call-id.js';
|
||||
import type {
|
||||
ExportConfig,
|
||||
ExportMessage,
|
||||
ExportSessionData,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Normalizes export session data by merging tool call information from tool_result records.
|
||||
|
|
@ -17,7 +22,7 @@ import type { ExportMessage, ExportSessionData } from './types.js';
|
|||
export function normalizeSessionData(
|
||||
sessionData: ExportSessionData,
|
||||
originalRecords: ChatRecord[],
|
||||
config: Config,
|
||||
config: ExportConfig,
|
||||
): ExportSessionData {
|
||||
const normalized = [...sessionData.messages];
|
||||
const toolCallIndexById = new Map<string, number>();
|
||||
|
|
@ -123,7 +128,7 @@ function mergeToolCallData(
|
|||
*/
|
||||
function buildToolCallMessageFromResult(
|
||||
record: ChatRecord,
|
||||
config: Config,
|
||||
config: ExportConfig,
|
||||
): ExportMessage | null {
|
||||
const toolCallResult = record.toolCallResult;
|
||||
const toolName = extractToolNameFromRecord(record);
|
||||
|
|
@ -134,7 +139,7 @@ function buildToolCallMessageFromResult(
|
|||
return null;
|
||||
}
|
||||
|
||||
const toolCallId = toolCallResult?.callId ?? record.uuid;
|
||||
const toolCallId = getToolResultCallId(record);
|
||||
const functionCallArgs = extractFunctionCallArgs(record);
|
||||
const { kind, title, locations } = resolveToolMetadata(
|
||||
config,
|
||||
|
|
@ -210,7 +215,7 @@ function extractFunctionCallArgs(
|
|||
* Resolves tool metadata (kind, title, locations) from tool registry.
|
||||
*/
|
||||
function resolveToolMetadata(
|
||||
config: Config,
|
||||
config: ExportConfig,
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
): {
|
||||
|
|
@ -225,7 +230,7 @@ function resolveToolMetadata(
|
|||
let locations: Array<{ path: string; line?: number | null }> | undefined;
|
||||
const kind = mapToolKind(tool?.kind as Kind | undefined, toolName);
|
||||
|
||||
if (tool && args) {
|
||||
if (tool?.build && args) {
|
||||
try {
|
||||
const invocation = tool.build(args);
|
||||
title = `${title}: ${invocation.getDescription()}`;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,31 @@
|
|||
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
|
||||
export interface ExportToolLocation {
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}
|
||||
|
||||
export interface ExportToolInvocation {
|
||||
getDescription(): string;
|
||||
toolLocations(): ExportToolLocation[];
|
||||
}
|
||||
|
||||
export interface ExportToolDefinition {
|
||||
displayName?: string;
|
||||
kind?: unknown;
|
||||
build?: (args: Record<string, unknown>) => ExportToolInvocation;
|
||||
}
|
||||
|
||||
export interface ExportToolRegistry {
|
||||
getTool?: (toolName: string) => ExportToolDefinition | null | undefined;
|
||||
}
|
||||
|
||||
export interface ExportConfig {
|
||||
getChannel?: () => string | undefined;
|
||||
getToolRegistry?: () => ExportToolRegistry | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal export message format - SSOT for all export formats.
|
||||
* This is format-agnostic and contains all information needed for any export type.
|
||||
|
|
|
|||
38
packages/cli/src/utils/chat-record-tool-call-id.ts
Normal file
38
packages/cli/src/utils/chat-record-tool-call-id.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export function getToolResultCallId(record: ChatRecord): string {
|
||||
return getExplicitToolResultCallId(record) ?? record.uuid;
|
||||
}
|
||||
|
||||
export function getExplicitToolResultCallId(
|
||||
record: ChatRecord,
|
||||
): string | undefined {
|
||||
const resultCallId = record.toolCallResult?.callId;
|
||||
if (typeof resultCallId === 'string' && resultCallId.length > 0) {
|
||||
return resultCallId;
|
||||
}
|
||||
|
||||
return extractFunctionResponseId(record);
|
||||
}
|
||||
|
||||
function extractFunctionResponseId(record: ChatRecord): string | undefined {
|
||||
if (!record.message?.parts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const part of record.message.parts) {
|
||||
const id =
|
||||
'functionResponse' in part ? part.functionResponse?.id : undefined;
|
||||
if (typeof id === 'string' && id.length > 0) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -34,6 +34,8 @@ import type {
|
|||
DaemonRestoredSession,
|
||||
DaemonSession,
|
||||
DaemonSessionArchiveState,
|
||||
DaemonSessionExportFormat,
|
||||
DaemonSessionExportResult,
|
||||
DaemonSessionLspStatus,
|
||||
DaemonSessionSummary,
|
||||
DaemonSessionSupportedCommandsStatus,
|
||||
|
|
@ -1441,6 +1443,42 @@ export class DaemonClient {
|
|||
return this.restoreSession('load', sessionId, req, clientId);
|
||||
}
|
||||
|
||||
async exportSession(
|
||||
sessionId: string,
|
||||
opts: {
|
||||
format?: DaemonSessionExportFormat;
|
||||
clientId?: string;
|
||||
} = {},
|
||||
): Promise<DaemonSessionExportResult> {
|
||||
const format = opts.format ?? 'html';
|
||||
const query = opts.format
|
||||
? `?format=${encodeURIComponent(opts.format)}`
|
||||
: '';
|
||||
return await this.fetchWithTimeout(
|
||||
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/export${query}`,
|
||||
{ headers: this.headers({}, opts.clientId) },
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
throw await this.failOnError(res, 'GET /session/:id/export');
|
||||
}
|
||||
const content = await res.text();
|
||||
const mimeType = res.headers.get('content-type') ?? '';
|
||||
const filename =
|
||||
/filename="([^"]+)"/i.exec(
|
||||
res.headers.get('content-disposition') ?? '',
|
||||
)?.[1] ?? `export.${format}`;
|
||||
return {
|
||||
content,
|
||||
filename,
|
||||
mimeType,
|
||||
format,
|
||||
};
|
||||
},
|
||||
undefined,
|
||||
'rest',
|
||||
);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
sessionId: string,
|
||||
req: RestoreSessionRequest = {},
|
||||
|
|
|
|||
|
|
@ -387,6 +387,8 @@ export type {
|
|||
DaemonRestoredSession,
|
||||
DaemonSession,
|
||||
DaemonSessionArchiveState,
|
||||
DaemonSessionExportFormat,
|
||||
DaemonSessionExportResult,
|
||||
DaemonAuthProviderId,
|
||||
DaemonAuthProviderBaseUrlOption,
|
||||
DaemonAuthProviderCatalog,
|
||||
|
|
|
|||
|
|
@ -385,6 +385,15 @@ export interface DaemonSessionSummary {
|
|||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
export type DaemonSessionExportFormat = 'html' | 'md' | 'json' | 'jsonl';
|
||||
|
||||
export interface DaemonSessionExportResult {
|
||||
content: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
format: DaemonSessionExportFormat;
|
||||
}
|
||||
|
||||
export type DaemonSessionArchiveState = 'active' | 'archived';
|
||||
|
||||
export interface DaemonArchiveSessionsResult {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
composeAbortSignals,
|
||||
normalizePendingPromptLimit,
|
||||
} from '../../src/daemon/DaemonClient.js';
|
||||
import type { DaemonTransport } from '../../src/daemon/DaemonTransport.js';
|
||||
import {
|
||||
DaemonCapabilityMissingError,
|
||||
isDaemonContentHash,
|
||||
|
|
@ -38,6 +39,14 @@ function jsonResponse(status: number, body: unknown): Response {
|
|||
});
|
||||
}
|
||||
|
||||
function textResponse(
|
||||
status: number,
|
||||
body: string,
|
||||
headers: Record<string, string> = {},
|
||||
): Response {
|
||||
return new Response(body, { status, headers });
|
||||
}
|
||||
|
||||
function sseResponse(frames: string): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
|
|
@ -745,6 +754,126 @@ describe('DaemonClient', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('exportSession', () => {
|
||||
it('GETs the default HTML export and parses attachment metadata', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
textResponse(200, '<html>export</html>', {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'content-disposition':
|
||||
'attachment; filename="qwen-code-export-2026.html"',
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
token: 'secret',
|
||||
fetch,
|
||||
});
|
||||
const exportClient = client as DaemonClient & {
|
||||
exportSession(
|
||||
sessionId: string,
|
||||
opts?: { format?: 'html' },
|
||||
): Promise<{
|
||||
content: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
format: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const result = await exportClient.exportSession('with/slash');
|
||||
|
||||
expect(result).toEqual({
|
||||
content: '<html>export</html>',
|
||||
filename: 'qwen-code-export-2026.html',
|
||||
mimeType: 'text/html; charset=utf-8',
|
||||
format: 'html',
|
||||
});
|
||||
expect(calls[0]).toMatchObject({
|
||||
url: 'http://daemon/session/with%2Fslash/export',
|
||||
method: 'GET',
|
||||
headers: { authorization: 'Bearer secret' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the requested export format', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
textResponse(200, '# export', {
|
||||
'content-type': 'text/markdown; charset=utf-8',
|
||||
'content-disposition': 'attachment; filename="session.md"',
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const exportClient = client as DaemonClient & {
|
||||
exportSession(
|
||||
sessionId: string,
|
||||
opts: { format: 'md' },
|
||||
): Promise<{ format: string }>;
|
||||
};
|
||||
|
||||
await exportClient.exportSession('s-1', { format: 'md' });
|
||||
|
||||
expect(calls[0]?.url).toBe(
|
||||
'http://daemon/session/s-1/export?format=md',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws DaemonHttpError on non-2xx', async () => {
|
||||
const { fetch } = recordingFetch(() =>
|
||||
jsonResponse(400, {
|
||||
error: 'Invalid export format',
|
||||
code: 'invalid_export_format',
|
||||
}),
|
||||
);
|
||||
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
|
||||
const exportClient = client as DaemonClient & {
|
||||
exportSession(sessionId: string): Promise<unknown>;
|
||||
};
|
||||
|
||||
await expect(exportClient.exportSession('s-1')).rejects.toBeInstanceOf(
|
||||
DaemonHttpError,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses direct REST fetch even when an ACP transport is configured', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
textResponse(200, '{}', {
|
||||
'content-type': 'application/json',
|
||||
'content-disposition': 'attachment; filename="session.json"',
|
||||
}),
|
||||
);
|
||||
const transportFetch = vi.fn(async () =>
|
||||
jsonResponse(500, { error: 'transport should not be used' }),
|
||||
);
|
||||
const transport: DaemonTransport = {
|
||||
type: 'acp-http',
|
||||
supportsReplay: true,
|
||||
connected: true,
|
||||
fetch: transportFetch,
|
||||
async *subscribeEvents() {},
|
||||
dispose() {},
|
||||
};
|
||||
const client = new DaemonClient({
|
||||
baseUrl: 'http://daemon',
|
||||
fetch,
|
||||
transport,
|
||||
});
|
||||
const exportClient = client as DaemonClient & {
|
||||
exportSession(
|
||||
sessionId: string,
|
||||
opts: { format: 'json' },
|
||||
): Promise<{ content: string }>;
|
||||
};
|
||||
|
||||
await expect(
|
||||
exportClient.exportSession('s-1', { format: 'json' }),
|
||||
).resolves.toMatchObject({ content: '{}' });
|
||||
expect(transportFetch).not.toHaveBeenCalled();
|
||||
expect(calls[0]?.url).toBe(
|
||||
'http://daemon/session/s-1/export?format=json',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer auth', () => {
|
||||
it('attaches Authorization: Bearer when token is set', async () => {
|
||||
const { fetch, calls } = recordingFetch(() =>
|
||||
|
|
|
|||
|
|
@ -331,9 +331,9 @@
|
|||
|
||||
.sessionMetaSlot {
|
||||
position: relative;
|
||||
width: 34px;
|
||||
width: 82px;
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
flex: 0 0 82px;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,38 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const {
|
||||
mockConnection,
|
||||
mockActive,
|
||||
mockArchived,
|
||||
renameSessionSpy,
|
||||
mockExportSession,
|
||||
} = vi.hoisted(() => {
|
||||
const makeStore = () => ({
|
||||
sessions: [] as MockSession[],
|
||||
loading: false,
|
||||
error: null as unknown,
|
||||
reload: vi.fn(),
|
||||
deleteSession: vi.fn().mockResolvedValue(true),
|
||||
archiveSession: vi.fn().mockResolvedValue(true),
|
||||
unarchiveSession: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
return {
|
||||
mockConnection: {
|
||||
status: 'connected',
|
||||
sessionId: null as string | null,
|
||||
workspaceCwd: '/tmp/project',
|
||||
capabilities: { qwenCodeVersion: '1.2.3', features: [] as string[] } as
|
||||
| { qwenCodeVersion?: string; features?: string[] }
|
||||
| undefined,
|
||||
},
|
||||
mockActive: makeStore(),
|
||||
mockArchived: makeStore(),
|
||||
renameSessionSpy: vi.fn(),
|
||||
mockExportSession: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
type MockSession = {
|
||||
sessionId: string;
|
||||
workspaceCwd: string;
|
||||
|
|
@ -14,37 +46,13 @@ type MockSession = {
|
|||
isArchived?: boolean;
|
||||
};
|
||||
|
||||
const { mockConnection, mockActive, mockArchived, renameSessionSpy } =
|
||||
vi.hoisted(() => {
|
||||
const makeStore = () => ({
|
||||
sessions: [] as MockSession[],
|
||||
loading: false,
|
||||
error: null as unknown,
|
||||
reload: vi.fn(),
|
||||
deleteSession: vi.fn().mockResolvedValue(true),
|
||||
archiveSession: vi.fn().mockResolvedValue(true),
|
||||
unarchiveSession: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
return {
|
||||
mockConnection: {
|
||||
status: 'connected',
|
||||
sessionId: null as string | null,
|
||||
workspaceCwd: '/tmp/project',
|
||||
capabilities: { qwenCodeVersion: '1.2.3' } as
|
||||
| { qwenCodeVersion?: string }
|
||||
| undefined,
|
||||
},
|
||||
mockActive: makeStore(),
|
||||
mockArchived: makeStore(),
|
||||
renameSessionSpy: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
||||
useConnection: () => mockConnection,
|
||||
useActions: () => ({ renameSession: renameSessionSpy }),
|
||||
useSessions: (options?: { archiveState?: 'active' | 'archived' }) =>
|
||||
options?.archiveState === 'archived' ? mockArchived : mockActive,
|
||||
options?.archiveState === 'archived'
|
||||
? mockArchived
|
||||
: { ...mockActive, exportSession: mockExportSession },
|
||||
}));
|
||||
|
||||
function makeSession(
|
||||
|
|
@ -79,6 +87,8 @@ function renderSidebar(
|
|||
overrides: Partial<{
|
||||
onOpenSettings: () => void;
|
||||
onOpenDaemonStatus: () => void;
|
||||
onLoadSession: (sessionId: string) => Promise<void> | void;
|
||||
onError: (error: unknown, message: string) => void;
|
||||
}> = {},
|
||||
): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
|
|
@ -105,18 +115,28 @@ function renderSidebar(
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockConnection.capabilities = { qwenCodeVersion: '1.2.3' };
|
||||
mockConnection.sessionId = null;
|
||||
mockConnection.capabilities = { qwenCodeVersion: '1.2.3', features: [] };
|
||||
for (const store of [mockActive, mockArchived]) {
|
||||
store.sessions = [];
|
||||
store.loading = false;
|
||||
store.error = null;
|
||||
store.reload.mockClear();
|
||||
store.deleteSession.mockClear();
|
||||
store.archiveSession.mockClear();
|
||||
store.unarchiveSession.mockClear();
|
||||
store.reload.mockReset();
|
||||
store.deleteSession.mockReset();
|
||||
store.archiveSession.mockReset();
|
||||
store.unarchiveSession.mockReset();
|
||||
store.deleteSession.mockResolvedValue(true);
|
||||
store.archiveSession.mockResolvedValue(true);
|
||||
store.unarchiveSession.mockResolvedValue(true);
|
||||
}
|
||||
renameSessionSpy.mockClear();
|
||||
mockExportSession.mockReset();
|
||||
mockExportSession.mockResolvedValue({
|
||||
content: '<html>export</html>',
|
||||
filename: 'session.html',
|
||||
mimeType: 'text/html',
|
||||
format: 'html',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -124,6 +144,8 @@ afterEach(() => {
|
|||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('WebShellSidebar — version footer', () => {
|
||||
|
|
@ -200,6 +222,122 @@ async function clickAsync(el: Element | null): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
describe('WebShellSidebar — session export', () => {
|
||||
it('hides export action when daemon does not advertise session_export', () => {
|
||||
mockActive.sessions = [makeSession('session-1')];
|
||||
const container = renderSidebar(false);
|
||||
|
||||
expect(
|
||||
container.querySelector('[aria-label="Export conversation record"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('downloads an HTML export when export action is clicked', async () => {
|
||||
mockConnection.capabilities = {
|
||||
qwenCodeVersion: '1.2.3',
|
||||
features: ['session_export'],
|
||||
};
|
||||
mockActive.sessions = [makeSession('session-1')];
|
||||
const createObjectURL = vi.fn(() => 'blob:session-export');
|
||||
const revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal('URL', {
|
||||
...URL,
|
||||
createObjectURL,
|
||||
revokeObjectURL,
|
||||
});
|
||||
const clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => {});
|
||||
const container = renderSidebar(false);
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Export conversation record"]',
|
||||
);
|
||||
|
||||
expect(button).not.toBeNull();
|
||||
await act(async () => {
|
||||
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
expect(mockExportSession).toHaveBeenCalledWith('session-1', 'html');
|
||||
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:session-export');
|
||||
});
|
||||
|
||||
it('does not block switching sessions while an export is running', async () => {
|
||||
mockConnection.capabilities = {
|
||||
qwenCodeVersion: '1.2.3',
|
||||
features: ['session_export'],
|
||||
};
|
||||
mockActive.sessions = [makeSession('session-1'), makeSession('session-2')];
|
||||
let resolveExport:
|
||||
| ((value: Awaited<ReturnType<typeof mockExportSession>>) => void)
|
||||
| undefined;
|
||||
mockExportSession.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveExport = resolve;
|
||||
}),
|
||||
);
|
||||
const createObjectURL = vi.fn(() => 'blob:session-export');
|
||||
vi.stubGlobal('URL', {
|
||||
...URL,
|
||||
createObjectURL,
|
||||
revokeObjectURL: vi.fn(),
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
const onLoadSession = vi.fn();
|
||||
const container = renderSidebar(false, { onLoadSession });
|
||||
const exportButton = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Export conversation record"]',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
exportButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
const secondSessionRow = Array.from(
|
||||
container.querySelectorAll<HTMLElement>('[role="button"]'),
|
||||
).find((el) => el.textContent?.includes('Session session-2'));
|
||||
click(secondSessionRow ?? null);
|
||||
|
||||
expect(onLoadSession).toHaveBeenCalledWith('session-2');
|
||||
|
||||
await act(async () => {
|
||||
resolveExport?.({
|
||||
content: '<html>export</html>',
|
||||
filename: 'session.html',
|
||||
mimeType: 'text/html',
|
||||
format: 'html',
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
|
||||
});
|
||||
|
||||
it('reports export failures through onError', async () => {
|
||||
mockConnection.capabilities = {
|
||||
qwenCodeVersion: '1.2.3',
|
||||
features: ['session_export'],
|
||||
};
|
||||
mockActive.sessions = [makeSession('session-1')];
|
||||
const error = new Error('download failed');
|
||||
mockExportSession.mockRejectedValueOnce(error);
|
||||
const onError = vi.fn();
|
||||
const container = renderSidebar(false, { onError });
|
||||
const button = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Export conversation record"]',
|
||||
);
|
||||
|
||||
expect(button).not.toBeNull();
|
||||
await act(async () => {
|
||||
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(error, 'Failed to export session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebShellSidebar — archive actions', () => {
|
||||
it('archives an active session from the quick action button', async () => {
|
||||
mockActive.sessions = [makeSession('aaaaaaaa')];
|
||||
|
|
|
|||
|
|
@ -154,6 +154,16 @@ function IconRename() {
|
|||
);
|
||||
}
|
||||
|
||||
function IconDownload() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M5 21h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTrash() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
|
|
@ -314,12 +324,19 @@ export function WebShellSidebar({
|
|||
const { t } = useI18n();
|
||||
const connection = useConnection();
|
||||
const actions = useActions();
|
||||
const { sessions, loading, error, reload, deleteSession, archiveSession } =
|
||||
useSessions({
|
||||
autoLoad: true,
|
||||
pageSize: SIDEBAR_SESSION_PAGE_SIZE,
|
||||
archiveState: 'active',
|
||||
});
|
||||
const {
|
||||
sessions,
|
||||
loading,
|
||||
error,
|
||||
reload,
|
||||
deleteSession,
|
||||
exportSession,
|
||||
archiveSession,
|
||||
} = useSessions({
|
||||
autoLoad: true,
|
||||
pageSize: SIDEBAR_SESSION_PAGE_SIZE,
|
||||
archiveState: 'active',
|
||||
});
|
||||
const [archivedExpanded, setArchivedExpanded] = useState(false);
|
||||
const {
|
||||
sessions: archivedSessions,
|
||||
|
|
@ -343,6 +360,10 @@ export function WebShellSidebar({
|
|||
const [editingName, setEditingName] = useState('');
|
||||
const [busySessionId, setBusySessionId] = useState<string | null>(null);
|
||||
const busySessionIdRef = useRef<string | null>(null);
|
||||
const [exportingSessionIds, setExportingSessionIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const exportingSessionIdsRef = useRef<Set<string>>(new Set());
|
||||
const creatingSessionRef = useRef(false);
|
||||
const [deleteCandidate, setDeleteCandidate] =
|
||||
useState<DaemonSessionSummary | null>(null);
|
||||
|
|
@ -366,6 +387,8 @@ export function WebShellSidebar({
|
|||
null,
|
||||
);
|
||||
const currentSessionId = connection.sessionId;
|
||||
const canExportSessions =
|
||||
connection.capabilities?.features?.includes('session_export') ?? false;
|
||||
const projectName =
|
||||
getWorkspaceName(connection.workspaceCwd) || t('sidebar.projectFallback');
|
||||
const qwenCodeVersion = connection.capabilities?.qwenCodeVersion || '';
|
||||
|
|
@ -633,6 +656,54 @@ export function WebShellSidebar({
|
|||
[currentSessionId],
|
||||
);
|
||||
|
||||
const setSessionExporting = useCallback(
|
||||
(sessionId: string, exporting: boolean) => {
|
||||
const next = new Set(exportingSessionIdsRef.current);
|
||||
if (exporting) {
|
||||
next.add(sessionId);
|
||||
} else {
|
||||
next.delete(sessionId);
|
||||
}
|
||||
exportingSessionIdsRef.current = next;
|
||||
setExportingSessionIds(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleExportSession = useCallback(
|
||||
(session: DaemonSessionSummary) => {
|
||||
const sessionId = session.sessionId;
|
||||
if (!canExportSessions || exportingSessionIdsRef.current.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
setSessionExporting(sessionId, true);
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await exportSession(sessionId, 'html');
|
||||
const blob = new Blob([result.content], {
|
||||
type: result.mimeType || 'text/html',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = result.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (err) {
|
||||
onError(err, t('sidebar.exportFailed'));
|
||||
} finally {
|
||||
setSessionExporting(sessionId, false);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[canExportSessions, exportSession, onError, setSessionExporting, t],
|
||||
);
|
||||
|
||||
const confirmDeleteSession = useCallback(() => {
|
||||
if (!deleteCandidate) return;
|
||||
const sessionId = deleteCandidate.sessionId;
|
||||
|
|
@ -855,6 +926,7 @@ export function WebShellSidebar({
|
|||
const stamp = session.updatedAt || session.createdAt;
|
||||
const time = stamp ? formatRelativeTime(stamp, t) : '';
|
||||
const busy = busySessionId === session.sessionId;
|
||||
const exporting = exportingSessionIds.has(session.sessionId);
|
||||
const completedUnread =
|
||||
!isCurrent && completedUnreadIds.has(session.sessionId);
|
||||
const isMenuOpen =
|
||||
|
|
@ -952,6 +1024,18 @@ export function WebShellSidebar({
|
|||
>
|
||||
<IconArchive />
|
||||
</button>
|
||||
{canExportSessions && (
|
||||
<button
|
||||
className={styles.sessionActionButton}
|
||||
type="button"
|
||||
disabled={exporting}
|
||||
title={t('sidebar.export')}
|
||||
aria-label={t('sidebar.export')}
|
||||
onClick={() => handleExportSession(session)}
|
||||
>
|
||||
<IconDownload />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={cx(
|
||||
styles.sessionActionButton,
|
||||
|
|
@ -977,6 +1061,7 @@ export function WebShellSidebar({
|
|||
});
|
||||
}, [
|
||||
busySessionId,
|
||||
canExportSessions,
|
||||
cancelRename,
|
||||
collapsed,
|
||||
completedUnreadIds,
|
||||
|
|
@ -984,8 +1069,10 @@ export function WebShellSidebar({
|
|||
editingName,
|
||||
editingSessionId,
|
||||
error,
|
||||
exportingSessionIds,
|
||||
filteredSessions,
|
||||
handleArchive,
|
||||
handleExportSession,
|
||||
handleLoadSession,
|
||||
hideTooltip,
|
||||
loading,
|
||||
|
|
|
|||
|
|
@ -503,6 +503,8 @@ const EN: Messages = {
|
|||
'sidebar.searchEmpty': 'No matching sessions.',
|
||||
'sidebar.rename': 'Rename',
|
||||
'sidebar.renameCurrentOnly': 'Only the current session can be renamed',
|
||||
'sidebar.export': 'Export conversation record',
|
||||
'sidebar.exportFailed': 'Failed to export session',
|
||||
'sidebar.delete': 'Delete',
|
||||
'sidebar.archive': 'Archive',
|
||||
'sidebar.unarchive': 'Restore',
|
||||
|
|
@ -1809,6 +1811,8 @@ const ZH: Messages = {
|
|||
'sidebar.searchEmpty': '没有匹配的会话。',
|
||||
'sidebar.rename': '重命名',
|
||||
'sidebar.renameCurrentOnly': '暂仅支持重命名当前会话',
|
||||
'sidebar.export': '导出对话记录',
|
||||
'sidebar.exportFailed': '导出会话失败',
|
||||
'sidebar.delete': '删除',
|
||||
'sidebar.archive': '归档',
|
||||
'sidebar.unarchive': '恢复',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type DaemonWorkspaceActions,
|
||||
type DaemonWorkspaceContextValue,
|
||||
} from './DaemonWorkspaceProvider.js';
|
||||
import { useDaemonSessions } from './hooks/useDaemonSessions.js';
|
||||
|
||||
const sdkMocks = vi.hoisted(() => {
|
||||
const capabilities = vi.fn();
|
||||
|
|
@ -36,6 +37,7 @@ const sdkMocks = vi.hoisted(() => {
|
|||
const workspaceProviders = vi.fn();
|
||||
const listWorkspaceSessions = vi.fn();
|
||||
const deleteSessionsData = vi.fn();
|
||||
const exportSession = vi.fn();
|
||||
const daemonStatus = vi.fn();
|
||||
|
||||
class MockDaemonClient {
|
||||
|
|
@ -59,6 +61,7 @@ const sdkMocks = vi.hoisted(() => {
|
|||
workspaceProviders = workspaceProviders;
|
||||
listWorkspaceSessions = listWorkspaceSessions;
|
||||
deleteSessionsData = deleteSessionsData;
|
||||
exportSession = exportSession;
|
||||
daemonStatus = daemonStatus;
|
||||
dispose = vi.fn();
|
||||
}
|
||||
|
|
@ -83,6 +86,7 @@ const sdkMocks = vi.hoisted(() => {
|
|||
workspaceProviders,
|
||||
listWorkspaceSessions,
|
||||
deleteSessionsData,
|
||||
exportSession,
|
||||
daemonStatus,
|
||||
reset() {
|
||||
capabilities.mockReset();
|
||||
|
|
@ -166,6 +170,13 @@ const sdkMocks = vi.hoisted(() => {
|
|||
notFound: [],
|
||||
errors: [],
|
||||
});
|
||||
exportSession.mockReset();
|
||||
exportSession.mockResolvedValue({
|
||||
content: '<html>export</html>',
|
||||
filename: 'session.html',
|
||||
mimeType: 'text/html',
|
||||
format: 'html',
|
||||
});
|
||||
daemonStatus.mockReset();
|
||||
daemonStatus.mockResolvedValue({
|
||||
v: 1,
|
||||
|
|
@ -562,6 +573,85 @@ describe('DaemonWorkspaceProvider', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('actions.exportSession calls client.exportSession for a session', async () => {
|
||||
let actions: DaemonWorkspaceActions | undefined;
|
||||
|
||||
function Harness() {
|
||||
const workspace = useOptionalDaemonWorkspace();
|
||||
actions = workspace?.actions;
|
||||
return null;
|
||||
}
|
||||
|
||||
await renderWithProvider(<Harness />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
if (!actions) throw new Error('actions not defined');
|
||||
|
||||
const workspaceActions = actions as DaemonWorkspaceActions & {
|
||||
exportSession(
|
||||
sessionId: string,
|
||||
format?: 'html',
|
||||
): Promise<{
|
||||
content: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
format: string;
|
||||
}>;
|
||||
};
|
||||
let result:
|
||||
| {
|
||||
content: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
format: string;
|
||||
}
|
||||
| undefined;
|
||||
await act(async () => {
|
||||
result = await workspaceActions.exportSession('session-123', 'html');
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
content: '<html>export</html>',
|
||||
filename: 'session.html',
|
||||
mimeType: 'text/html',
|
||||
format: 'html',
|
||||
});
|
||||
expect(sdkMocks.exportSession).toHaveBeenCalledWith('session-123', {
|
||||
format: 'html',
|
||||
});
|
||||
});
|
||||
|
||||
it('useDaemonSessions exposes exportSession', async () => {
|
||||
let exportSession:
|
||||
| ReturnType<typeof useDaemonSessions>['exportSession']
|
||||
| undefined;
|
||||
|
||||
function Harness() {
|
||||
exportSession = useDaemonSessions({
|
||||
autoLoad: false,
|
||||
}).exportSession;
|
||||
return null;
|
||||
}
|
||||
|
||||
await renderWithProvider(<Harness />);
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
if (!exportSession) throw new Error('exportSession not defined');
|
||||
const runExportSession = exportSession;
|
||||
|
||||
await act(async () => {
|
||||
await runExportSession('session-456', 'jsonl');
|
||||
});
|
||||
|
||||
expect(sdkMocks.exportSession).toHaveBeenCalledWith('session-456', {
|
||||
format: 'jsonl',
|
||||
});
|
||||
});
|
||||
|
||||
it('actions.loadDaemonStatus forwards the detail level to client.daemonStatus', async () => {
|
||||
const report = {
|
||||
v: 1,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,14 @@ export function createDaemonWorkspaceActions({
|
|||
);
|
||||
},
|
||||
|
||||
async exportSession(sessionId, format = 'html') {
|
||||
const client = requireClient(getClient, 'Export session failed');
|
||||
return withActionTimeout(
|
||||
client.exportSession(sessionId, { format }),
|
||||
'Export session timed out',
|
||||
);
|
||||
},
|
||||
|
||||
async archiveSession(sessionId: string) {
|
||||
const client = requireClient(getClient, 'Archive session failed');
|
||||
const result = await withActionTimeout(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import type { DaemonSessionArchiveState } from '@qwen-code/sdk/daemon';
|
||||
import type {
|
||||
DaemonSessionArchiveState,
|
||||
DaemonSessionExportFormat,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import { useOptionalDaemonActions } from '../../session/DaemonSessionProvider.js';
|
||||
import { useDaemonWorkspace } from '../DaemonWorkspaceProvider.js';
|
||||
import type { DaemonResourceOptions } from '../types.js';
|
||||
|
|
@ -47,6 +50,11 @@ export function useDaemonSessions(options: DaemonSessionsOptions = {}) {
|
|||
},
|
||||
[workspace.actions, reload],
|
||||
);
|
||||
const exportSession = useCallback(
|
||||
(sessionId: string, format: DaemonSessionExportFormat = 'html') =>
|
||||
workspace.actions.exportSession(sessionId, format),
|
||||
[workspace.actions],
|
||||
);
|
||||
const archiveSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
const archived = await workspace.actions.archiveSession(sessionId);
|
||||
|
|
@ -72,6 +80,7 @@ export function useDaemonSessions(options: DaemonSessionsOptions = {}) {
|
|||
releaseSession: sessionActions?.releaseSession,
|
||||
deleteSession,
|
||||
deleteSessions,
|
||||
exportSession,
|
||||
archiveSession,
|
||||
unarchiveSession,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ import type {
|
|||
DaemonWorkspaceSettingsStatus,
|
||||
DaemonSettingUpdateResult,
|
||||
DaemonSessionSummary,
|
||||
DaemonSessionExportFormat,
|
||||
DaemonSessionExportResult,
|
||||
DaemonStatusReport,
|
||||
DaemonStatusReportDetail,
|
||||
DaemonWriteMemoryRequest,
|
||||
|
|
@ -155,6 +157,10 @@ export interface DaemonWorkspaceActions {
|
|||
notFound: string[];
|
||||
errors: Array<{ sessionId: string; error: string }>;
|
||||
}>;
|
||||
exportSession(
|
||||
sessionId: string,
|
||||
format?: DaemonSessionExportFormat,
|
||||
): Promise<DaemonSessionExportResult>;
|
||||
/**
|
||||
* Move a session to the archived directory. Idempotent: an
|
||||
* already-archived session resolves `true`. Rejects if the daemon
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue