Skip to content

Commit 128dfa3

Browse files
feat(api): session export endpoint — download transcript as JSONL/Markdown (#3114)
Adds GET /v1/sessions/:id/export?format=jsonl|markdown: - JSONL: NDJSON stream with proper content-disposition header - Markdown: readable export with section headers, thinking blocks, tool calls in collapsible <details>, permission requests, errors - 400 for unsupported formats - 404 for non-existent sessions or sessions with no transcript data - Auth/ownership checks via withOwnership middleware - 9 tests covering all cases
1 parent d2633d9 commit 128dfa3

2 files changed

Lines changed: 280 additions & 0 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* Issue #3114: Session export API — download full transcript as JSONL/Markdown.
3+
*/
4+
import { describe, it, expect, vi, beforeEach } from 'vitest';
5+
import Fastify from 'fastify';
6+
import { registerSessionDataRoutes } from '../routes/session-data.js';
7+
import type { RouteContext } from '../routes/context.js';
8+
import type { SessionInfo } from '../session.js';
9+
import type { ParsedEntry } from '../transcript.js';
10+
11+
const SESSION_ID = 'export-test-session';
12+
const AUTH_TOKEN = 'test-token';
13+
14+
function makeSession(): SessionInfo {
15+
return {
16+
id: SESSION_ID,
17+
windowId: '',
18+
displayName: 'export-test',
19+
workDir: '/tmp',
20+
byteOffset: 0,
21+
monitorOffset: 0,
22+
status: 'idle',
23+
createdAt: Date.now(),
24+
lastActivity: Date.now(),
25+
stallThresholdMs: 30_000,
26+
permissionStallMs: 60_000,
27+
permissionMode: 'default',
28+
};
29+
}
30+
31+
const sampleEntries: ParsedEntry[] = [
32+
{ role: 'user', contentType: 'text', text: 'Hello, how are you?' },
33+
{ role: 'assistant', contentType: 'thinking', text: 'Let me think about this...' },
34+
{ role: 'assistant', contentType: 'text', text: 'I am doing well, thanks!' },
35+
{ role: 'assistant', contentType: 'tool_use', text: 'ReadFile', toolName: 'ReadFile', toolUseId: 'tc-1' },
36+
{ role: 'assistant', contentType: 'tool_result', text: 'file contents here', toolUseId: 'tc-1' },
37+
{ role: 'assistant', contentType: 'tool_error', text: 'Permission denied', toolUseId: 'tc-2' },
38+
{ role: 'system', contentType: 'permission_request', text: 'Allow write to foo.txt' },
39+
];
40+
41+
function buildApp(withData = false): { app: ReturnType<typeof Fastify>; sessions: Record<string, unknown> } {
42+
const app = Fastify({ logger: false });
43+
app.addHook('onRequest', async (req) => {
44+
req.authKeyId = null;
45+
req.tenantId = 'system';
46+
});
47+
48+
const session = makeSession();
49+
const sessions = {
50+
getSession: vi.fn((id: string) => id === SESSION_ID ? session : undefined),
51+
readTranscript: vi.fn(async () => ({
52+
messages: withData ? sampleEntries : [],
53+
total: withData ? sampleEntries.length : 0,
54+
page: 1,
55+
limit: 100_000,
56+
hasMore: false,
57+
})),
58+
};
59+
60+
const ctx = {
61+
sessions,
62+
auth: { authEnabled: false },
63+
config: {},
64+
metrics: { getSessionMetrics: vi.fn() },
65+
monitor: {},
66+
eventBus: { subscribe: vi.fn() },
67+
channels: {},
68+
toolRegistry: { processEntries: vi.fn(), getSessionTools: vi.fn(() => []), getToolDefinitions: vi.fn(() => []) },
69+
sseLimiter: { acquire: vi.fn(() => ({ allowed: false, reason: 'test' })) },
70+
} as unknown as RouteContext;
71+
72+
registerSessionDataRoutes(app, ctx);
73+
return { app, sessions };
74+
}
75+
76+
describe('Issue #3114: Session export API', () => {
77+
it('returns 400 for unsupported format', async () => {
78+
const { app } = buildApp();
79+
const res = await app.inject({
80+
method: 'GET',
81+
url: `/v1/sessions/${SESSION_ID}/export?format=csv`,
82+
});
83+
expect(res.statusCode).toBe(400);
84+
expect(res.json()).toEqual({ error: expect.stringContaining('Invalid format') });
85+
await app.close();
86+
});
87+
88+
it('returns 404 for non-existent session', async () => {
89+
const { app } = buildApp();
90+
const res = await app.inject({
91+
method: 'GET',
92+
url: '/v1/sessions/nonexistent/export?format=jsonl',
93+
});
94+
expect(res.statusCode).toBe(404);
95+
await app.close();
96+
});
97+
98+
it('returns 404 for session with no transcript data', async () => {
99+
const { app } = buildApp(false); // no data
100+
const res = await app.inject({
101+
method: 'GET',
102+
url: `/v1/sessions/${SESSION_ID}/export?format=jsonl`,
103+
});
104+
expect(res.statusCode).toBe(404);
105+
expect(res.json()).toEqual({ error: expect.stringContaining('No transcript data') });
106+
await app.close();
107+
});
108+
109+
it('returns NDJSON for jsonl format with data', async () => {
110+
const { app } = buildApp(true); // with data
111+
const res = await app.inject({
112+
method: 'GET',
113+
url: `/v1/sessions/${SESSION_ID}/export?format=jsonl`,
114+
});
115+
expect(res.statusCode).toBe(200);
116+
expect(res.headers['content-type']).toContain('application/x-ndjson');
117+
expect(res.headers['content-disposition']).toContain(`session-${SESSION_ID}.jsonl`);
118+
119+
const lines = res.body.split('\n').filter(Boolean);
120+
expect(lines.length).toBe(sampleEntries.length);
121+
for (const line of lines) {
122+
const parsed = JSON.parse(line);
123+
expect(parsed).toHaveProperty('role');
124+
expect(parsed).toHaveProperty('contentType');
125+
}
126+
await app.close();
127+
});
128+
129+
it('returns markdown for markdown format with data', async () => {
130+
const { app } = buildApp(true);
131+
const res = await app.inject({
132+
method: 'GET',
133+
url: `/v1/sessions/${SESSION_ID}/export?format=markdown`,
134+
});
135+
expect(res.statusCode).toBe(200);
136+
expect(res.headers['content-type']).toContain('text/markdown');
137+
expect(res.headers['content-disposition']).toContain(`session-${SESSION_ID}.md`);
138+
expect(res.body).toContain('# Session Export');
139+
expect(res.body).toContain('export-test');
140+
expect(res.body).toContain('Hello, how are you?');
141+
expect(res.body).toContain('Let me think about this');
142+
expect(res.body).toContain('🔧 Tool: ReadFile');
143+
expect(res.body).toContain('<details>');
144+
await app.close();
145+
});
146+
147+
it('defaults to jsonl format when no format specified', async () => {
148+
const { app } = buildApp(true);
149+
const res = await app.inject({
150+
method: 'GET',
151+
url: `/v1/sessions/${SESSION_ID}/export`,
152+
});
153+
expect(res.statusCode).toBe(200);
154+
expect(res.headers['content-type']).toContain('application/x-ndjson');
155+
await app.close();
156+
});
157+
158+
it('markdown includes thinking blocks', async () => {
159+
const { app } = buildApp(true);
160+
const res = await app.inject({
161+
method: 'GET',
162+
url: `/v1/sessions/${SESSION_ID}/export?format=markdown`,
163+
});
164+
expect(res.body).toContain('💭 Thinking');
165+
expect(res.body).toContain('Let me think about this...');
166+
await app.close();
167+
});
168+
169+
it('markdown includes permission request', async () => {
170+
const { app } = buildApp(true);
171+
const res = await app.inject({
172+
method: 'GET',
173+
url: `/v1/sessions/${SESSION_ID}/export?format=markdown`,
174+
});
175+
expect(res.body).toContain('🔐 Permission Request');
176+
expect(res.body).toContain('Allow write to foo.txt');
177+
await app.close();
178+
});
179+
180+
it('markdown includes tool error with warning', async () => {
181+
const { app } = buildApp(true);
182+
const res = await app.inject({
183+
method: 'GET',
184+
url: `/v1/sessions/${SESSION_ID}/export?format=markdown`,
185+
});
186+
expect(res.body).toContain('⚠️ Tool error');
187+
expect(res.body).toContain('Permission denied');
188+
await app.close();
189+
});
190+
});

src/routes/session-data.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,96 @@ export function registerSessionDataRoutes(app: FastifyInstance, ctx: RouteContex
233233
// Issue #2461: /stream alias for /events SSE
234234
registerWithLegacy(app, 'get', '/v1/sessions/:id/stream', sessionEventsHandler);
235235

236+
237+
// Issue #3114: Session export — download full transcript as JSONL or Markdown
238+
registerWithLegacy(app, 'get', '/v1/sessions/:id/export', withOwnership(sessions, async (req: FastifyRequest, reply: FastifyReply, session) => {
239+
const query = req.query as { format?: string };
240+
const format = query.format ?? 'jsonl';
241+
242+
if (format !== 'jsonl' && format !== 'markdown') {
243+
return reply.status(400).send({ error: `Invalid format: ${format}. Supported: jsonl, markdown` });
244+
}
245+
246+
// Read full transcript
247+
const sessionId = (req.params as { id: string }).id;
248+
const transcript = await sessions.readTranscript(sessionId, 1, 100_000);
249+
const entries = transcript.messages;
250+
251+
if (entries.length === 0) {
252+
return reply.status(404).send({ error: 'No transcript data available for this session' });
253+
}
254+
255+
if (format === 'jsonl') {
256+
// NDJSON — one JSON object per line
257+
const lines = entries.map(e => JSON.stringify({
258+
role: e.role,
259+
contentType: e.contentType,
260+
text: e.text,
261+
...(e.toolName ? { toolName: e.toolName } : {}),
262+
...(e.toolUseId ? { toolUseId: e.toolUseId } : {}),
263+
...(e.timestamp ? { timestamp: e.timestamp } : {}),
264+
}));
265+
return reply
266+
.header('Content-Type', 'application/x-ndjson')
267+
.header('Content-Disposition', `attachment; filename="session-${session.id}.jsonl"`)
268+
.send(lines.join('\n'));
269+
}
270+
271+
// Markdown format
272+
const mdParts: string[] = [
273+
`# Session Export: ${session.displayName || session.id}`,
274+
``,
275+
`> Exported: ${new Date().toISOString()}`,
276+
`> Session ID: ${session.id}`,
277+
`> Status: ${session.status}`,
278+
``,
279+
`---`,
280+
``,
281+
];
282+
283+
for (const entry of entries) {
284+
if (entry.contentType === 'tool_use') {
285+
mdParts.push(`### 🔧 Tool: ${entry.toolName || 'unknown'}`);
286+
mdParts.push('');
287+
mdParts.push(`<details>`);
288+
mdParts.push(`<summary>${entry.text || entry.toolName || 'Tool call'}</summary>`);
289+
mdParts.push('');
290+
} else if (entry.contentType === 'tool_result' || entry.contentType === 'tool_error') {
291+
const isError = entry.contentType === 'tool_error';
292+
if (isError) {
293+
mdParts.push(`> ⚠️ Tool error: ${entry.text?.slice(0, 200) || 'unknown error'}`);
294+
} else {
295+
mdParts.push(entry.text?.slice(0, 2000) || '');
296+
}
297+
mdParts.push('');
298+
mdParts.push(`</details>`);
299+
mdParts.push('');
300+
} else if (entry.contentType === 'thinking') {
301+
mdParts.push(`### 💭 Thinking`);
302+
mdParts.push('');
303+
mdParts.push(entry.text || '');
304+
mdParts.push('');
305+
} else if (entry.contentType === 'permission_request') {
306+
mdParts.push(`### 🔐 Permission Request`);
307+
mdParts.push('');
308+
mdParts.push(`> ${entry.text || 'Permission requested'}`);
309+
mdParts.push('');
310+
} else {
311+
// Regular text message
312+
const roleLabel = entry.role === 'user' ? '👤 User' : entry.role === 'system' ? '⚙️ System' : '🤖 Assistant';
313+
mdParts.push(`### ${roleLabel}`);
314+
mdParts.push('');
315+
mdParts.push(entry.text || '');
316+
mdParts.push('');
317+
}
318+
}
319+
320+
return reply
321+
.header('Content-Type', 'text/markdown; charset=utf-8')
322+
.header('Content-Disposition', `attachment; filename="session-${session.id}.md"`)
323+
.send(mdParts.join('\n'));
324+
}));
325+
236326
// ── Claude Code Hook Endpoints (Issue #161) ─────────────────────
237327
// Permission hook — validates body with withValidation, looks up session manually
238328
// (Claude Code calls this directly, not through API user auth)

0 commit comments

Comments
 (0)