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:
jinye 2026-07-04 17:33:44 +08:00 committed by GitHub
parent 0e684a3444
commit 59e771cef6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1170 additions and 97 deletions

View file

@ -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',

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -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;

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

View file

@ -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',
});
});
});

View file

@ -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(

View file

@ -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';

View file

@ -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' },
},
]);
});
});

View file

@ -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()}`;

View file

@ -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.

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

View file

@ -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 = {},

View file

@ -387,6 +387,8 @@ export type {
DaemonRestoredSession,
DaemonSession,
DaemonSessionArchiveState,
DaemonSessionExportFormat,
DaemonSessionExportResult,
DaemonAuthProviderId,
DaemonAuthProviderBaseUrlOption,
DaemonAuthProviderCatalog,

View file

@ -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 {

View file

@ -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(() =>

View file

@ -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;

View file

@ -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')];

View file

@ -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,

View file

@ -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': '恢复',

View file

@ -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,

View file

@ -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(

View file

@ -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,
};

View file

@ -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