diff --git a/README.md b/README.md index b9ca1c2..f85c8a1 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Install any plugin directly in VS Code: |---|---| | [dap-cli](https://github.com/roblourens/dap-cli) ↗ | DAP debugger controls — breakpoints, stepping, stack and variable inspection, and launch configuration | | [debug](debug/) | Hypothesis-driven temporary instrumentation and local structured runtime evidence, with mandatory human reproduction and cleanup gates | +| [session-timeline](session-timeline/) | Live session timeline canvas with nested tool/subagent activity, prompt inspection, filters, and search | ### Test — show testers how to verify a change diff --git a/marketplace.json b/marketplace.json index 4054e8f..310c5d5 100644 --- a/marketplace.json +++ b/marketplace.json @@ -36,6 +36,15 @@ } ], "plugins": [ + { + "name": "session-timeline", + "source": "./session-timeline/", + "description": "Inspect the current Copilot session as a live, searchable timeline canvas.", + "version": "0.1.5", + "categories": ["debugging"], + "status": "experimental", + "keywords": ["canvas", "session", "timeline", "debugging", "copilot"] + }, { "name": "handy-commands", "source": "./handy-commands/", diff --git a/session-timeline/.playwright-mcp/page-2026-10-22T09-19-26-636Z.yml b/session-timeline/.playwright-mcp/page-2026-10-22T09-19-26-636Z.yml new file mode 100644 index 0000000..e0d2d4c --- /dev/null +++ b/session-timeline/.playwright-mcp/page-2026-10-22T09-19-26-636Z.yml @@ -0,0 +1,43 @@ +- generic [active] [ref=e1]: + - link "Skip to timeline" [ref=e2] [cursor=pointer]: + - /url: "#events" + - banner [ref=e3]: + - heading "Session timeline" [level=1] [ref=e5] + - generic [ref=e6]: + - generic [ref=e7]: + - text: Search + - searchbox "Search" [ref=e8] + - button "Refresh" [ref=e9] [cursor=pointer] + - link "Export events" [ref=e10] [cursor=pointer]: + - /url: /api/raw + - button "Text view" [ref=e11] [cursor=pointer] + - button "Help" [ref=e12] [cursor=pointer] + - generic [ref=e13]: + - group "Show events" [ref=e14]: + - generic [ref=e16]: + - checkbox "System" [checked] [ref=e17] + - text: System + - generic [ref=e18]: + - checkbox "User" [checked] [ref=e19] + - text: User + - generic [ref=e20]: + - checkbox "Assistant" [checked] [ref=e21] + - text: Assistant + - generic [ref=e22]: + - checkbox "Tool" [checked] [ref=e23] + - text: Tool + - generic [ref=e24]: + - checkbox "Subagent" [checked] [ref=e25] + - text: Subagent + - generic [ref=e26]: + - checkbox "Follow latest" [ref=e27] + - text: Follow latest + - generic [ref=e28]: + - checkbox "Announce updates" [checked] [ref=e29] + - text: Announce updates + - status [ref=e30]: Loading session history... + - generic [ref=e31]: + - navigation "User requests" [ref=e32]: + - heading "Requests" [level=2] [ref=e33] + - list + - main "Session timeline" [ref=e34] \ No newline at end of file diff --git a/session-timeline/.playwright-mcp/page-2026-10-22T09-20-30-302Z.yml b/session-timeline/.playwright-mcp/page-2026-10-22T09-20-30-302Z.yml new file mode 100644 index 0000000..f8acb49 --- /dev/null +++ b/session-timeline/.playwright-mcp/page-2026-10-22T09-20-30-302Z.yml @@ -0,0 +1,43 @@ +- generic [active] [ref=f1e1]: + - link "Skip to timeline" [ref=f1e2] [cursor=pointer]: + - /url: "#events" + - banner [ref=f1e3]: + - heading "Session timeline" [level=1] [ref=f1e5] + - generic [ref=f1e6]: + - generic [ref=f1e7]: + - text: Search + - searchbox "Search" [ref=f1e8] + - button "Refresh" [ref=f1e9] [cursor=pointer] + - link "Export events" [ref=f1e10] [cursor=pointer]: + - /url: /api/raw + - button "Text view" [ref=f1e11] [cursor=pointer] + - button "Help" [ref=f1e12] [cursor=pointer] + - generic [ref=f1e13]: + - group "Show events" [ref=f1e14]: + - generic [ref=f1e16]: + - checkbox "System" [checked] [ref=f1e17] + - text: System + - generic [ref=f1e18]: + - checkbox "User" [checked] [ref=f1e19] + - text: User + - generic [ref=f1e20]: + - checkbox "Assistant" [checked] [ref=f1e21] + - text: Assistant + - generic [ref=f1e22]: + - checkbox "Tool" [checked] [ref=f1e23] + - text: Tool + - generic [ref=f1e24]: + - checkbox "Subagent" [checked] [ref=f1e25] + - text: Subagent + - generic [ref=f1e26]: + - checkbox "Follow latest" [ref=f1e27] + - text: Follow latest + - generic [ref=f1e28]: + - checkbox "Announce updates" [checked] [ref=f1e29] + - text: Announce updates + - status [ref=f1e30]: Loading session history... + - generic [ref=f1e31]: + - navigation "User requests" [ref=f1e32]: + - heading "Requests" [level=2] [ref=f1e33] + - list + - main "Session timeline" [ref=f1e34] \ No newline at end of file diff --git a/session-timeline/.plugin/plugin.json b/session-timeline/.plugin/plugin.json new file mode 100644 index 0000000..128d516 --- /dev/null +++ b/session-timeline/.plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "session-timeline", + "version": "0.1.5", + "description": "Inspect the current Copilot session as a live, searchable timeline canvas.", + "license": "MIT", + "categories": ["debugging"], + "status": "experimental", + "keywords": ["canvas", "session", "timeline", "debugging", "copilot"], + "extensions": "extensions/" +} diff --git a/session-timeline/CHANGELOG.md b/session-timeline/CHANGELOG.md new file mode 100644 index 0000000..fd6a8a6 --- /dev/null +++ b/session-timeline/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +## 0.1.5 + +- Include the date in user-request timestamps. +- Put the expand indicator before the category and tool name, with status and duration inline. +- Hide empty tool-argument preview placeholders. + +## 0.1.4 + +- Keep Find visible above the sticky event headers. +- Debounce Find by 200 ms and match in a cancellable background worker. +- Avoid rebuilding the timeline while typing and populate matching details only when navigating to them. + +## 0.1.3 + +- Stack all event headers flush at the top and highlight the current request in navigation. +- Move section outlines into inline pills, showing assistant model, response size, and tool-request count. +- Remove generated ellipses and repetitive tool-request previews. +- Replace search filtering with Find, match counts, and next/previous navigation into collapsed details. + +## 0.1.2 + +- Keep user-request cards sticky while scrolling through their nested activity. +- Show request timestamps only, simplify core event labels, and add clickable section outlines. +- Replace collapsed tool-argument JSON with concise readable descriptions and paths. + +## 0.1.1 + +- Add a toolbar action to collapse all expanded timeline events. + +## 0.1.0 + +- Add a live Copilot session timeline canvas with turn navigation, nested tool and subagent activity, category filters, search, readable details, and raw event export. +- Add keyboard accessibility help and a plain-text timeline view. diff --git a/session-timeline/README.md b/session-timeline/README.md new file mode 100644 index 0000000..131daf0 --- /dev/null +++ b/session-timeline/README.md @@ -0,0 +1,74 @@ +# Session timeline + +Inspect the current Copilot session as a live, searchable timeline canvas. + +> **Experimental:** Requires a Copilot host supporting SDK canvas extensions. No additional npm dependencies are needed; the host resolves `@github/copilot-sdk/extension`. + +## Skills + +| Skill | Description | +|---|---| +| [session-timeline](skills/session-timeline/) | Opens the current session's live timeline, with optional search and category filtering | + +## Getting started + +Install this plugin from the marketplace, or use your host's plugin-from-source workflow with the local `session-timeline` directory. In VS Code, run **Chat: Install Plugin from Source** and enter that directory's absolute path. Enable the plugin, then ask “show the session timeline” or invoke `/session-timeline`. + +## How it works + +The extension joins the current session, subscribes before fetching `session.getEvents()`, and merges events by their stable IDs to avoid a history/live-update race. It groups user turns and nests system, assistant, tool, and subagent activity by semantic ownership rather than treating the chronological `parentId` chain as a tree. History is refreshed after truncation or context clearing. + +The canvas provides: + +- Sticky request navigation with the current request highlighted. All event headers stack flush at the top while scrolling their content and descendants. +- Find across readable details, with raw-record fallback, a match counter, and wrapping Next/Previous navigation (Enter/Shift+Enter). Find opens matching details without filtering the tree; category filters still retain context ancestors. +- Find stays pinned above the timeline. Matching runs in a background worker after a 200 ms typing delay; each new keystroke cancels stale work. Live updates defer timeline rebuilding while the Find input has focus. +- Inline section pills on System and Assistant headers, including tool-request counts, model names, and UTF-8 content/response sizes. +- User requests show the local date and time. Tool headers keep the expand indicator, category, name, status pill, and duration together; only useful argument previews are shown. +- Expandable prompts, reasoning, tool arguments/results/errors, durations, and raw SDK records. Collapsed cards show concise section outlines with direct links to their details; tool summaries prefer readable descriptions and paths over JSON. +- Collapse all expanded events at once, including events hidden by the current filters. +- System-prompt summaries for advertised skills, tool-guidance headings, and instruction headings. +- Live updates, optional follow-latest behavior, manual refresh, and SDK-event JSONL export. +- Native keyboard-operable controls, visible focus indicators, accessibility help (Alt+F1), and a plain-text view (Alt+F2). Announcements can be disabled in the toolbar. + +`vscode-session-timeline` exposes `getStatus`, `setFilter`, and `refresh` actions. It takes no open input. Reopening an existing instance reuses its server and current filters/expansion state. + +## Data and privacy + +All processing is local to the extension. A loopback-only HTTP server serves the canvas with host validation, a same-site HTTP-only session cookie, same-origin mutation checks, and a content security policy. Event content is rendered as text, never executable HTML or agent-authored links. There is no telemetry, model call, token request, or third-party resource loading. + +The extension does not read private session paths or write event history to disk. View state is in memory, keyed by canvas instance within the joined session; it survives hiding/reopening the panel but resets when the extension restarts. **Export events** is an explicit browser download. Exported files can contain sensitive prompts, paths, tool output, and reasoning: review before sharing. + +The SDK's public durable event history is not a byte-for-byte copy of `events.jsonl`: internal skill/usage records may be filtered or resolved. Streaming deltas are not rendered, and past ephemeral events cannot be recovered. Tool guidance extracted from prompt text is not an authoritative model-visible tool inventory. Optional fields depend on the runtime/provider. The canvas inspects only its joined session; offline imports and arbitrary-session browsing are not included. + +## Validation + +```sh +node --check session-timeline/extensions/session-timeline/extension.mjs +node --test session-timeline/test/*.test.mjs +``` + +## Plugin structure + +```text +session-timeline/ +├── .plugin/plugin.json +├── CHANGELOG.md +├── README.md +├── skills/session-timeline/SKILL.md +├── extensions/session-timeline/ +│ ├── extension.mjs +│ ├── model.mjs +│ ├── source.mjs +│ ├── server.mjs +│ └── public/ +│ ├── index.html +│ ├── find-worker.mjs +│ ├── timeline.css +│ └── timeline.mjs +└── test/ + ├── find-worker.test.mjs + ├── model.test.mjs + ├── source.test.mjs + └── server.test.mjs +``` diff --git a/session-timeline/extensions/session-timeline/extension.mjs b/session-timeline/extensions/session-timeline/extension.mjs new file mode 100644 index 0000000..409c425 --- /dev/null +++ b/session-timeline/extensions/session-timeline/extension.mjs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT license. + +import { createCanvas, CanvasError, joinSession } from '@github/copilot-sdk/extension'; +import { categories } from './model.mjs'; +import { TimelineSource } from './source.mjs'; +import { createTimelineServer } from './server.mjs'; + +const instances = new Map(); +let source; +let starting; +let session; + +async function getSource() { + if (!starting) { + source = new TimelineSource(session); + starting = source.start().then(() => source).catch(error => { + source.dispose(); + starting = undefined; + throw new CanvasError('HISTORY_UNAVAILABLE', `Failed to load session history: ${error.message}`); + }); + } + return starting; +} + +async function instance(instanceId) { + const pending = instances.get(instanceId); + if (!pending) { + throw new CanvasError('NOT_OPEN', 'Open the session timeline first.'); + } + return pending; +} + +session = await joinSession({ + canvases: [createCanvas({ + id: 'vscode-session-timeline', + displayName: 'Session timeline', + description: 'Inspect the current session as a live timeline of prompts, responses, tools, and subagents.', + async open({ instanceId }) { + if (!instances.has(instanceId)) { + const pending = getSource().then(createTimelineServer).catch(error => { + instances.delete(instanceId); + throw error instanceof CanvasError ? error : new CanvasError('OPEN_FAILED', error.message); + }); + instances.set(instanceId, pending); + } + return { url: (await instances.get(instanceId)).url }; + }, + actions: [ + { + name: 'getStatus', + description: 'Return timeline event counts and any loading error.', + async handler({ instanceId }) { + await instance(instanceId); + const snapshot = source.snapshot(); + return { sessionId: snapshot.sessionId, totalRecords: snapshot.totalRecords, visibleEvents: snapshot.events.length, error: snapshot.error }; + }, + }, + { + name: 'setFilter', + description: 'Set the Find text or visible event categories without changing session history.', + inputSchema: { + type: 'object', additionalProperties: false, + properties: { query: { type: 'string', maxLength: 2000 }, categories: { type: 'array', maxItems: 5, items: { type: 'string', enum: categories } } }, + }, + async handler({ instanceId, input }) { + try { + return (await instance(instanceId)).setFilter(input); + } catch (error) { + throw error instanceof CanvasError ? error : new CanvasError('INVALID_FILTER', error.message); + } + }, + }, + { + name: 'refresh', + description: 'Reload SDK session history, preserving timeline view state.', + async handler({ instanceId }) { + await instance(instanceId); + try { + await source.refresh(); + return { totalRecords: source.snapshot().totalRecords }; + } catch (error) { + throw new CanvasError('HISTORY_UNAVAILABLE', error.message); + } + }, + }, + ], + async onClose({ instanceId }) { + const pending = instances.get(instanceId); + instances.delete(instanceId); + if (pending) { + await (await pending).close(); + } + if (!instances.size) { + source?.dispose(); + starting = undefined; + } + }, + })], +}); diff --git a/session-timeline/extensions/session-timeline/model.mjs b/session-timeline/extensions/session-timeline/model.mjs new file mode 100644 index 0000000..069b194 --- /dev/null +++ b/session-timeline/extensions/session-timeline/model.mjs @@ -0,0 +1,265 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT license. + +export const categories = ['system', 'user', 'assistant', 'tool', 'subagent']; + +const completionTypes = new Map([ + ['tool.execution_start', 'tool.execution_complete'], + ['external_tool.requested', 'external_tool.completed'], + ['subagent.started', 'subagent.completed'], +]); + +export function readable(value) { + return typeof value === 'string' ? value : JSON.stringify(value, null, 2) ?? ''; +} + +export function sectionOutlines(sections) { + const groups = new Map(); + for (const [index, section] of sections.entries()) { + let label = section.label; + let count = 1; + if (label === 'Advertised skills') { + count = section.content.split('\n').filter(Boolean).length; + } else if (label.startsWith('Tool guidance:')) { + label = 'Tool guidance'; + } else if (label === 'Tool requests') { + count = section.count ?? 0; + } else if (label.startsWith('Instruction:')) { + label = 'Instructions'; + } + const group = groups.get(label) ?? { label, count: 0, index }; + group.count += count; + groups.set(label, group); + } + return [...groups.values()].map(group => { + if (['Content', 'Response'].includes(group.label)) { + const bytes = new TextEncoder().encode(sections[group.index].content).byteLength; + const size = bytes < 1000 ? `${bytes}B` : `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(bytes / 1000)}kB`; + return { ...group, text: `${group.label} ${size}` }; + } + if (group.label === 'Model') { + return { ...group, text: sections[group.index].content }; + } + const showCount = ['Advertised skills', 'Tool guidance', 'Instructions', 'Tool requests'].includes(group.label) || group.count > 1; + return { ...group, text: showCount ? `${group.label} ${group.count}` : group.label }; + }); +} + +function text(value) { + return typeof value === 'string' ? value : ''; +} + +function key(record, id) { + return JSON.stringify([record.agentId ?? '', id]); +} + +function correlationId(record) { + return record.type.startsWith('external_tool.') ? record.data.requestId : record.data.toolCallId; +} + +function section(label, value) { + return { label, content: readable(value) }; +} + +function toolSummary(value) { + const compact = item => item.slice(0, 300); + const args = typeof value === 'string' ? (() => { + try { + return JSON.parse(value); + } catch { + return value; + } + })() : value; + if (!args || typeof args !== 'object' || Array.isArray(args)) { + return typeof args === 'string' ? compact(args) : ''; + } + const preferred = ['description', 'path', 'filePath', 'file', 'uri', 'url', 'query', 'command', 'pattern', 'symbol']; + const entries = preferred + .map(name => [name, args[name]]) + .filter(([, item]) => typeof item === 'string' && item.trim()) + .map(([name, item]) => [name, compact(item)]) + .slice(0, 4); + if (!entries.length) { + return ''; + } + return entries.map(([name, item]) => `${name}: ${item}`).join(' · '); +} + +function promptSections(content) { + const sections = []; + const skills = [...content.matchAll(/\s*[\s\S]*?([\s\S]*?)<\/name>[\s\S]*?<\/skill>/gi)] + .map(match => match[1].trim()).filter(Boolean); + if (skills.length) { + sections.push(section('Advertised skills', [...new Set(skills)].join('\n'))); + } + const toolsBlock = /]*)?>([\s\S]*?)<\/tools>/i.exec(content); + if (toolsBlock) { + for (const tool of ['bash', 'view', 'skill', 'ask_user', 'sql', 'rg', 'task']) { + const guidance = new RegExp(`<${tool}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tool}>`, 'i').exec(toolsBlock[1]); + if (guidance) { + sections.push(section(`Tool guidance: ${tool}`, guidance[1].trim())); + } + } + } + const blocks = []; + for (const match of content.matchAll(/<([A-Za-z_][A-Za-z0-9_.-]*)(?:\s[^>]*)?>/g)) { + const name = match[1]; + const end = content.indexOf(``, match.index + match[0].length); + if (end >= 0) { + blocks.push({ name, start: match.index, end: end + name.length + 3, content: content.slice(match.index + match[0].length, end) }); + } + } + for (const block of blocks.filter(block => block.name !== 'tools' && block.name !== 'available_skills' + && !blocks.some(parent => parent.start < block.start && parent.end >= block.end))) { + sections.push(section(`Instruction: ${block.name.replace(/[_.-]+/g, ' ')}`, block.content.trim())); + } + return sections; +} + +export function validateEvent(event) { + if (!event || typeof event !== 'object' || typeof event.id !== 'string' + || typeof event.type !== 'string' || typeof event.timestamp !== 'string' + || (event.parentId !== null && typeof event.parentId !== 'string') + || (event.agentId !== undefined && typeof event.agentId !== 'string') + || !event.data || typeof event.data !== 'object' || Array.isArray(event.data)) { + throw new Error('The SDK returned an invalid session event envelope.'); + } + return event; +} + +/** Build semantic ownership without mistaking the chronological parentId chain for a tree. */ +export function createTimelineModel(records) { + const completions = new Map(); + const starts = new Map(); + const owners = new Map(); + for (const record of records) { + validateEvent(record); + const { data, type } = record; + if (type === 'assistant.message') { + for (const request of Array.isArray(data.toolRequests) ? data.toolRequests : []) { + if (request && typeof request === 'object') { + owners.set(key(record, request.toolCallId ?? request.id), record.id); + } + } + } + if (type === 'tool.execution_start') { + starts.set(key(record, data.toolCallId), record.id); + } + if ([...completionTypes.values(), 'subagent.failed'].includes(type)) { + completions.set(`${type}:${key(record, correlationId(record))}`, record); + } + } + const paired = new Set(); + const state = new Map(); + const events = []; + for (const record of records) { + if (paired.has(record.id)) { + continue; + } + const { type, data } = record; + const agent = record.agentId ?? ''; + const current = state.get(agent) ?? {}; + state.set(agent, current); + // Child assistant/tool records identify the spawning call, whose owner is in the parent agent. + const parentCall = data.parentToolCallId; + const parentTool = parentCall + ? starts.get(key(record, parentCall)) ?? starts.get(key({ agentId: undefined }, parentCall)) + ?? [...starts.entries()].find(([id]) => JSON.parse(id)[1] === parentCall)?.[1] + : undefined; + const event = { + id: record.id, category: '', title: '', summary: '', timestamp: record.timestamp, + agentId: record.agentId, parentEventId: undefined, sections: [], rawRecords: [record], + status: '', duration: undefined, + }; + if (type === 'user.message') { + event.category = 'user'; + event.title = 'User'; + event.summary = text(data.content); + event.parentEventId = parentTool; + event.sections.push(section('User request', data.content)); + if (data.transformedContent && data.transformedContent !== data.content) { + event.sections.push(section('Transformed prompt', data.transformedContent)); + } + if (data.attachments?.length) { + event.sections.push(section('Attachments', data.attachments)); + } + current.user = record.id; + current.system = undefined; + current.assistant = undefined; + } else if (type === 'system.message') { + event.category = 'system'; + event.title = 'System'; + event.summary = text(data.content); + event.parentEventId = current.user ?? parentTool; + event.sections = [...promptSections(text(data.content)), section('Content', data.content)]; + current.system = record.id; + } else if (type === 'assistant.message') { + event.category = 'assistant'; + event.title = 'Assistant'; + event.summary = text(data.content); + event.parentEventId = parentTool ?? current.system ?? current.user; + for (const [label, value] of [['Response', data.content], ['Reasoning', data.reasoningText], ['Tool requests', data.toolRequests], ['Model', data.model]]) { + if (value) { + event.sections.push({ ...section(label, value), ...(label === 'Tool requests' && Array.isArray(value) ? { count: value.length } : {}) }); + } + } + current.assistant = record.id; + } else if (completionTypes.has(type)) { + const id = correlationId(record); + const completionKey = `${completionTypes.get(type)}:${key(record, id)}`; + const completion = completions.get(completionKey) + ?? (type === 'subagent.started' ? completions.get(`subagent.failed:${key(record, id)}`) : undefined); + if (completion) { + paired.add(completion.id); + event.rawRecords.push(completion); + const duration = Date.parse(completion.timestamp) - Date.parse(record.timestamp); + if (Number.isFinite(duration) && duration >= 0) { + event.duration = duration; + } + } + event.category = type === 'subagent.started' ? 'subagent' : 'tool'; + event.title = event.category === 'subagent' + ? text(data.agentDisplayName) || text(data.agentName) || 'Subagent' + : text(data.toolName) || 'Tool'; + event.summary = event.category === 'subagent' + ? text(data.agentDescription) + : toolSummary(data.arguments); + event.parentEventId = parentTool + ?? (event.category === 'subagent' ? starts.get(key(record, data.toolCallId)) : undefined) + ?? owners.get(key(record, data.toolCallId)) ?? current.assistant ?? current.user; + event.status = !completion ? 'Running' : completion.type === 'subagent.failed' || completion.data.success === false ? 'Failed' : 'Completed'; + event.sections.push(section(event.category === 'tool' ? 'Arguments' : 'Agent details', event.category === 'tool' ? data.arguments : data)); + if (completion) { + event.sections.push(section(event.status === 'Failed' ? 'Error' : 'Result', completion.data.result ?? completion.data.error ?? completion.data)); + } + } else if ([...completionTypes.values(), 'subagent.failed'].includes(type)) { + event.category = type.startsWith('subagent.') ? 'subagent' : 'tool'; + event.title = type === 'subagent.failed' ? 'Subagent failed' : 'Unpaired completion'; + event.summary = 'No matching start event is present in this history.'; + event.parentEventId = current.assistant ?? current.user; + event.sections.push(section('Details', data)); + } else { + continue; + } + event.searchText = [event.title, event.summary, ...event.sections.map(s => `${s.label}\n${s.content}`), readable(event.rawRecords)].join('\n'); + events.push(event); + } + return { totalRecords: records.length, events }; +} + +export function selectEvents(events, query, selectedCategories) { + const byId = new Map(events.map(event => [event.id, event])); + const selected = new Set(selectedCategories); + const visible = new Set(); + const search = query.toLocaleLowerCase(); + for (const event of events) { + if (!selected.has(event.category) || !event.searchText.toLocaleLowerCase().includes(search)) { + continue; + } + let ancestor = event; + while (ancestor && !visible.has(ancestor.id)) { + visible.add(ancestor.id); + ancestor = byId.get(ancestor.parentEventId); + } + } + return events.filter(event => visible.has(event.id)); +} diff --git a/session-timeline/extensions/session-timeline/public/find-worker.mjs b/session-timeline/extensions/session-timeline/public/find-worker.mjs new file mode 100644 index 0000000..d2713bb --- /dev/null +++ b/session-timeline/extensions/session-timeline/public/find-worker.mjs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT license. + +let events = []; +let generation = 0; + +self.onmessage = ({ data }) => { + generation++; + if (data.type === 'index') { + events = data.events; + } else if (data.type === 'find') { + void find(data.query, data.id, generation); + } +}; + +async function find(query, id, current) { + const needle = query.toLocaleLowerCase(); + const matches = []; + let deadline = performance.now() + 8; + for (const event of events) { + const readableMatches = []; + const sections = [...event.sections.map(section => section.content), JSON.stringify(event.rawRecords, null, 2)]; + for (const [section, content] of sections.entries()) { + if (section === event.sections.length && readableMatches.length) { + break; + } + const haystack = content.toLocaleLowerCase(); + let offset = 0; + let occurrence = 0; + while (needle && (offset = haystack.indexOf(needle, offset)) !== -1) { + readableMatches.push({ id: event.id, section, occurrence: occurrence++ }); + offset += needle.length; + if (performance.now() >= deadline) { + await new Promise(resolve => setTimeout(resolve, 0)); + if (current !== generation) { + return; + } + deadline = performance.now() + 8; + } + } + if (performance.now() >= deadline) { + await new Promise(resolve => setTimeout(resolve, 0)); + if (current !== generation) { + return; + } + deadline = performance.now() + 8; + } + } + for (const match of readableMatches) { + matches.push(match); + } + } + if (current === generation) { + self.postMessage({ id, query, matches }); + } +} diff --git a/session-timeline/extensions/session-timeline/public/index.html b/session-timeline/extensions/session-timeline/public/index.html new file mode 100644 index 0000000..fbb73d9 --- /dev/null +++ b/session-timeline/extensions/session-timeline/public/index.html @@ -0,0 +1,44 @@ + + + + + + + Session timeline + + + + + +
+

Session timeline

+
+ + 0 matches + + + + + Export events + + +
+
+
Show events
+ + +
+

Loading session history...

+ +
+
+ +
+
+ +

+

+		
+
+ + diff --git a/session-timeline/extensions/session-timeline/public/timeline.css b/session-timeline/extensions/session-timeline/public/timeline.css new file mode 100644 index 0000000..5648a1a --- /dev/null +++ b/session-timeline/extensions/session-timeline/public/timeline.css @@ -0,0 +1,82 @@ +/* Copyright (c) Microsoft Corporation. Licensed under the MIT license. */ +:root { + color-scheme: light dark; + --surface: light-dark(#ffffff, #1f1f1f); + --recessed: light-dark(#f6f6f6, #181818); + --foreground: light-dark(#242424, #d4d4d4); + --muted: light-dark(#616161, #a0a0a0); + --border: light-dark(#d4d4d4, #414141); + --accent: light-dark(#005fb8, #75beff); + --focus: light-dark(#005fb8, #75beff); + --radius-control: 4px; + --radius-inner: 6px; +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--vscode-editor-background, var(--surface)); color: var(--vscode-editor-foreground, var(--foreground)); font: 13px/1.5 system-ui, sans-serif; } +button, input, a.button { font: inherit; color: inherit; } +button, a.button, input[type="search"] { border: 1px solid var(--vscode-widget-border, var(--border)); border-radius: var(--radius-control); background: transparent; padding: 4px 8px; } +button, summary, a { cursor: pointer; } +button:hover, a.button:hover, summary:hover { background: var(--vscode-list-hoverBackground, var(--recessed)); } +a { color: var(--vscode-textLink-foreground, var(--accent)); } +.button { text-decoration: none; } +:focus-visible { outline: 2px solid var(--vscode-focusBorder, var(--focus)); outline-offset: 2px; } +.skip { position: fixed; top: -100px; left: 8px; z-index: 10; padding: 8px; background: var(--surface); } +.skip:focus { top: 8px; } +.toolbar { position: sticky; top: 0; z-index: 200; padding: 16px 20px 8px; background: var(--vscode-editor-background, var(--surface)); border-bottom: 1px solid var(--vscode-widget-border, var(--border)); } +.heading, .controls, .options, fieldset { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } +.heading { justify-content: space-between; } +h1 { font-size: 20px; font-weight: 600; margin: 0 0 12px; } +h2 { font-size: 13px; font-weight: 600; margin: 0 0 12px; } +#session, #status, .meta, .context { color: var(--vscode-descriptionForeground, var(--muted)); font-size: 12px; } +.search { display: flex; align-items: center; gap: 8px; flex: 1; } +input[type="search"] { min-width: 120px; width: 100%; background: var(--vscode-input-background, var(--recessed)); } +.options { margin-top: 12px; gap: 16px; } +fieldset { padding: 0; border: 0; margin: 0; } +legend { float: left; margin-right: 8px; color: var(--muted); } +label { display: inline-flex; align-items: center; gap: 4px; } +#status { margin: 8px 0 0; } +#error { border-left: 3px solid var(--vscode-errorForeground, #e55); padding-left: 8px; } +.layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 24px; padding: 20px; } +nav#request-nav { position: sticky; top: var(--toolbar-height, 200px); max-height: calc(100vh - var(--toolbar-height, 200px)); overflow: auto; align-self: start; } +nav#request-nav ol { list-style: none; padding: 0; margin: 0; } +nav#request-nav a { display: block; padding: 6px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: var(--radius-control); text-decoration: none; } +nav#request-nav a:hover { background: var(--recessed); } +nav#request-nav a[aria-current] { background: var(--vscode-list-activeSelectionBackground, var(--recessed)); color: var(--vscode-list-activeSelectionForeground, var(--foreground)); font-weight: 600; box-shadow: inset 3px 0 var(--accent); } +article { margin: 0 0 12px; min-width: 0; } +.event-header { position: sticky; top: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; padding: 8px 10px; border: 1px solid var(--vscode-widget-border, var(--border)); border-radius: var(--radius-inner); background: var(--vscode-editor-background, var(--surface)); } +.event-user > .event-header { background: var(--vscode-sideBar-background, var(--recessed)); } +.event-toggle { display: block; padding: 0; border: 0; text-align: left; background: transparent; } +.event-user > .event-header > .event-toggle { width: 100%; } +.event-toggle:hover { background: transparent; } +.event-title-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; } +.event-toggle .event-title-line::before { content: "+"; display: inline-block; width: 8px; } +.event-toggle[aria-expanded="true"] .event-title-line::before { content: "-"; } +.title { font-weight: 600; } +.category { font-size: 12px; color: var(--muted); margin-right: 8px; text-transform: capitalize; } +.event-time { margin-left: auto; color: var(--vscode-descriptionForeground, var(--muted)); font-size: 12px; font-variant-numeric: tabular-nums; } +.summary { order: 2; flex-basis: 100%; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; max-height: 4.5em; overflow: hidden; } +.context { display: block; white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 8px; } +.meta { display: block; color: var(--vscode-descriptionForeground, var(--muted)); font-size: 12px; } +.section-outline { display: flex; flex-wrap: wrap; gap: 4px 6px; } +.section-outline a, .status-pill { font-size: 12px; text-decoration: none; padding: 1px 8px; border: 1px solid var(--vscode-widget-border, var(--border)); border-radius: 12px; background: var(--vscode-badge-background, var(--recessed)); color: var(--vscode-badge-foreground, var(--foreground)); } +.children { margin: 8px 0 0 16px; padding-left: 12px; border-left: 1px solid var(--vscode-widget-border, var(--border)); } +.details { padding: 10px 12px 12px; border: 1px solid var(--vscode-widget-border, var(--border)); border-top: 0; border-radius: 0 0 var(--radius-inner) var(--radius-inner); } +.details[hidden] { display: none; } +.details h3 { font-size: 12px; font-weight: 600; margin: 16px 0 6px; color: var(--muted); } +.details h3, .details mark { scroll-margin-top: var(--sticky-height, 0px); } +pre { font: 12px/1.6 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; } +mark { color: var(--vscode-editor-findMatchHighlightForeground, inherit); background: var(--vscode-editor-findMatchHighlightBackground, light-dark(#ffe297, #654c16)); } +mark.current-match { outline: 2px solid var(--vscode-focusBorder, var(--focus)); } +dialog { width: min(900px, 90vw); max-height: 85vh; padding: 24px; color: var(--foreground); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; } +dialog pre { max-height: 60vh; overflow: auto; margin: 12px 0 20px; } +dialog::backdrop { background: #0007; } +@media (max-width: 760px) { + .layout { grid-template-columns: minmax(0, 1fr); padding: 12px; } + nav#request-nav { position: static; max-height: 160px; } + .toolbar { padding: 12px; } + .children { margin-left: 4px; padding-left: 6px; } +} +@media (forced-colors: active) { + .event-header, .details, button, input, dialog { border-color: CanvasText; } + mark { color: HighlightText; background: Highlight; } +} diff --git a/session-timeline/extensions/session-timeline/public/timeline.mjs b/session-timeline/extensions/session-timeline/public/timeline.mjs new file mode 100644 index 0000000..0e66c5c --- /dev/null +++ b/session-timeline/extensions/session-timeline/public/timeline.mjs @@ -0,0 +1,499 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT license. + +import { categories, selectEvents, sectionOutlines } from '/model.mjs'; + +const $ = id => document.getElementById(id); +let snapshot; +let revision = ''; +let saveTimer; +let editing = false; +let saveVersion = 0; +let restoreFocus; +let matches = []; +let matchIndex = -1; +let findQuery = ''; +let findTimer; +let findVersion = 0; +let pendingRender = false; +const findWorker = new Worker('/find-worker.mjs', { type: 'module' }); +let view = { query: '', categories: [...categories], expanded: [], follow: false, announcements: true }; +const headerObserver = new ResizeObserver(() => updateStickyHeaders()); + +function showError(error) { + $('error').textContent = error instanceof Error ? error.message : String(error); + $('error').hidden = false; +} + +async function request(path, input) { + const response = await fetch(path, input === undefined ? undefined : { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), + }); + if (!response.ok) { + const result = await response.json(); + throw new Error(result.error ?? `Timeline request failed (${response.status}).`); + } + return response; +} + +function save() { + editing = true; + const version = ++saveVersion; + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + void request('/api/view', view).then(() => { + if (version === saveVersion) { + editing = false; + } + }).catch(error => { + if (version === saveVersion) { + editing = false; + } + showError(error); + }); + }, 150); +} + +function highlighted(container, content) { + const query = container.tagName === 'PRE' ? findQuery.toLocaleLowerCase() : ''; + const string = String(content ?? ''); + let index = 0; + let match; + while (query && (match = string.toLocaleLowerCase().indexOf(query, index)) !== -1) { + container.append(document.createTextNode(string.slice(index, match))); + const mark = document.createElement('mark'); + mark.textContent = string.slice(match, match + query.length); + container.append(mark); + index = match + query.length; + } + container.append(document.createTextNode(string.slice(index))); +} + +function element(tag, className, content) { + const node = document.createElement(tag); + if (className) { + node.className = className; + } + if (content !== undefined) { + highlighted(node, content); + } + return node; +} + +function showDialog(title, content) { + restoreFocus = document.activeElement; + $('dialog-title').textContent = title; + $('dialog-content').textContent = content; + $('dialog').showModal(); + $('dialog-content').focus(); +} + +function help() { + showDialog('Session timeline help', [ + 'Inspect the current session without sending a model request.', + 'Tab and Shift+Tab move between controls and event summaries.', + 'Enter or Space on an event summary expands its readable details and raw SDK records.', + 'Find highlights details without filtering the tree. Enter/Shift+Enter or Next/Previous navigate matches, including collapsed details.', + 'Category filters select matches; dimmed context labels identify retained ancestors.', + 'Parent and current event headers stack at the top while scrolling. Requests highlights the current user turn.', + 'Section outlines jump directly to readable details; raw tool JSON is available only inside expanded cards.', + 'Collapse all closes every expanded event, including events hidden by filters.', + 'Alt+F1 opens this help. Alt+F2 opens the filtered timeline as plain text.', + 'Escape closes this dialog and restores focus.', + 'Disable Announce updates to silence routine status announcements.', + 'Export events downloads SDK history, which may include sensitive prompts and tool output.', + ].join('\n\n')); +} + +function visibleEvents() { + return selectEvents(snapshot?.events ?? [], '', view.categories); +} + +function updateStickyHeaders() { + // Each header is constrained by its article, but stacks below all ancestor headers. + const toolbarHeight = document.querySelector('.toolbar').getBoundingClientRect().height; + document.documentElement.style.setProperty('--toolbar-height', `${toolbarHeight}px`); + for (const article of $('events').querySelectorAll('article')) { + const parent = article.parentElement.closest('article'); + const parentHeader = parent?.querySelector(':scope > .event-header'); + const top = parentHeader ? Number(parentHeader.dataset.stickyTop) + parentHeader.getBoundingClientRect().height : toolbarHeight; + const header = article.querySelector(':scope > .event-header'); + header.dataset.stickyTop = String(top); + header.style.top = `${top}px`; + header.style.zIndex = String(100 - Number(article.dataset.depth)); + article.style.scrollMarginTop = `${top}px`; + article.querySelector(':scope > .details').style.setProperty('--sticky-height', `${top + header.getBoundingClientRect().height}px`); + } + updateActiveRequest(); +} + +function updateActiveRequest() { + const toolbarBottom = document.querySelector('.toolbar').getBoundingClientRect().bottom; + const requests = [...$('events').querySelectorAll('article.event-user')].filter(article => !article.dataset.agent); + const active = requests.find(article => { + const rect = article.getBoundingClientRect(); + return rect.top <= toolbarBottom + 1 && rect.bottom > toolbarBottom; + }) ?? requests.find(article => article.getBoundingClientRect().bottom > 0); + for (const link of $('requests').querySelectorAll('a')) { + if (link.dataset.requestId === active?.id) { + link.setAttribute('aria-current', 'location'); + } else { + link.removeAttribute('aria-current'); + } + } +} + +function updateFind() { + if (matchIndex >= matches.length) { + matchIndex = matches.length - 1; + } + $('find-count').textContent = matchIndex >= 0 ? `${matchIndex + 1} of ${matches.length}` : `${matches.length} matches`; + $('find-next').disabled = $('find-prev').disabled = matches.length === 0; +} + +function scheduleFind() { + clearTimeout(findTimer); + const id = ++findVersion; + findWorker.postMessage({ type: 'cancel' }); + matches = []; + matchIndex = -1; + updateFind(); + $('events').querySelector('mark.current-match')?.classList.remove('current-match'); + if (!view.query) { + findQuery = ''; + return; + } + $('find-count').textContent = 'Searching...'; + findTimer = setTimeout(() => { + findWorker.postMessage({ type: 'find', query: view.query, id }); + }, 200); +} + +findWorker.onmessage = ({ data }) => { + if (data.id !== findVersion) { + return; + } + findQuery = data.query; + matches = data.matches; + matchIndex = -1; + updateFind(); +}; +findWorker.onerror = event => { + clearTimeout(findTimer); + $('find-count').textContent = 'Find failed'; + showError(new Error(`Background Find failed: ${event.message}`)); +}; + +function moveMatch(direction) { + if (!matches.length) { + return; + } + matchIndex = matchIndex < 0 ? (direction > 0 ? 0 : matches.length - 1) : (matchIndex + direction + matches.length) % matches.length; + const match = matches[matchIndex]; + const details = $(`details-${encodeURIComponent(match.id)}`); + const toggle = details.parentElement.querySelector(':scope > .event-header .event-toggle'); + // Populate only the visited card rather than all matching records on every keystroke. + details.replaceChildren(); + details.dispatchEvent(new Event('populate')); + const mark = details.querySelectorAll('pre')[match.section]?.querySelectorAll('mark')[match.occurrence]; + if (!mark) { + showError(new Error('The selected Find match is no longer available. Try finding again.')); + return; + } + $('events').querySelector('mark.current-match')?.classList.remove('current-match'); + mark.classList.add('current-match'); + details.hidden = false; + toggle.setAttribute('aria-expanded', 'true'); + if (!view.expanded.includes(toggle.dataset.eventId)) { + view.expanded.push(toggle.dataset.eventId); + save(); + } + updateFind(); + updateStickyHeaders(); + const offset = parseFloat(details.style.getPropertyValue('--sticky-height')) || 0; + window.scrollTo(0, window.scrollY + mark.getBoundingClientRect().top - offset - 4); +} + +function textView() { + showDialog('Timeline text view', visibleEvents().map(event => [ + `${event.category === 'user' ? `${event.timestamp} ` : ''}${['user', 'system', 'assistant'].includes(event.category) ? event.title : `${event.category.toUpperCase()}: ${event.title}`}`, + event.summary, event.status, + ...event.sections.map(section => `${section.label}\n${section.content}`), + ].filter(Boolean).join('\n\n')).join('\n\n----------------\n\n') || 'No matching events.'); +} + +function render() { + const events = visibleEvents(); + const focusedId = document.activeElement?.dataset.eventId; + const scrollY = window.scrollY; + const byId = new Map(events.map(event => [event.id, event])); + const children = new Map(); + for (const event of events) { + const parent = byId.has(event.parentEventId) ? event.parentEventId : undefined; + const siblings = children.get(parent) ?? []; + siblings.push(event); + children.set(parent, siblings); + } + const fragment = document.createDocumentFragment(); + headerObserver.disconnect(); + function appendEvents(parentId, container, ancestors = new Set()) { + for (const event of children.get(parentId) ?? []) { + if (ancestors.has(event.id)) { + continue; + } + const article = element('article'); + const eventKey = encodeURIComponent(event.id); + article.id = `event-${eventKey}`; + article.className = `event event-${event.category}`; + article.dataset.depth = String(ancestors.size); + article.dataset.agent = event.agentId ?? ''; + const expanded = view.expanded.includes(event.id); + const header = element('header', 'event-header'); + const toggle = element('button', 'event-toggle'); + toggle.type = 'button'; + toggle.dataset.eventId = event.id; + toggle.setAttribute('aria-expanded', String(expanded)); + toggle.setAttribute('aria-controls', `details-${eventKey}`); + const titleLine = element('span', 'event-title-line'); + if (!['user', 'system', 'assistant'].includes(event.category)) { + titleLine.append(element('span', 'category', event.category)); + } + titleLine.append(element('span', 'title', event.title)); + if (event.category === 'user') { + const time = element('time', 'event-time', new Date(event.timestamp).toLocaleString()); + time.dateTime = event.timestamp; + titleLine.append(time); + } + toggle.append(titleLine); + if (event.status) { + titleLine.append(element('span', 'status-pill', event.status)); + } + const metadata = [event.duration !== undefined ? `${event.duration.toLocaleString()} ms` : '', event.agentId ? `Agent ${event.agentId}` : ''].filter(Boolean).join(' · '); + if (metadata) { + titleLine.append(element('span', 'meta', metadata)); + } + const preview = event.category === 'system' ? '' : event.category === 'assistant' ? event.sections.find(section => section.label === 'Response')?.content : event.summary; + const matching = view.categories.includes(event.category); + if (!matching) { + toggle.append(element('span', 'context', 'Context for matching activity')); + } + header.append(toggle); + if (preview) { + const previewNode = element('div', 'summary', preview); + previewNode.addEventListener('click', () => toggle.click()); + header.append(previewNode); + } + const outline = element('div', 'section-outline'); + outline.setAttribute('role', 'group'); + outline.setAttribute('aria-label', `${event.title} sections`); + for (const item of sectionOutlines(event.sections)) { + const link = element('a', '', item.text); + link.href = `#section-${eventKey}-${item.index}`; + link.addEventListener('click', click => { + click.preventDefault(); + click.stopPropagation(); + if (!view.expanded.includes(event.id)) { + view.expanded = [...view.expanded, event.id]; + save(); + } + toggle.setAttribute('aria-expanded', 'true'); + content.hidden = false; + populate(); + $('collapse').disabled = false; + updateStickyHeaders(); + document.getElementById(`section-${eventKey}-${item.index}`)?.scrollIntoView({ block: 'start' }); + }); + outline.append(link); + } + const content = element('div', 'details'); + content.id = `details-${eventKey}`; + content.hidden = !expanded; + function populate() { + if (content.childNodes.length) { + return; + } + const navigation = element('div', 'controls'); + for (const [index, section] of [...event.sections, { label: 'Raw SDK records', content: JSON.stringify(event.rawRecords, null, 2) }].entries()) { + const heading = element('h3', '', section.label); + heading.id = `section-${eventKey}-${index}`; + const link = element('a', '', section.label); + link.href = `#${heading.id}`; + navigation.append(link); + content.append(heading, element('pre', index === event.sections.length ? 'raw-records' : '', section.content)); + } + content.prepend(navigation); + } + content.addEventListener('populate', populate); + if (expanded) { + populate(); + } + toggle.addEventListener('click', () => { + const isExpanded = toggle.getAttribute('aria-expanded') === 'true'; + if (!isExpanded) { + populate(); + } + const expanded = new Set(view.expanded); + if (isExpanded) { + expanded.delete(event.id); + } else { + expanded.add(event.id); + } + view.expanded = [...expanded]; + content.hidden = isExpanded; + toggle.setAttribute('aria-expanded', String(!isExpanded)); + $('collapse').disabled = view.expanded.length === 0; + updateFind(); + updateStickyHeaders(); + save(); + }); + if (outline.childNodes.length) { + header.append(outline); + } + article.append(header, content); + if (children.has(event.id)) { + const nested = element('div', 'children'); + appendEvents(event.id, nested, new Set([...ancestors, event.id])); + article.append(nested); + } + container.append(article); + headerObserver.observe(header); + } + } + appendEvents(undefined, fragment); + if (!events.length) { + fragment.append(element('p', '', 'No matching events. Adjust the search or category filters.')); + } + $('events').replaceChildren(fragment); + const links = events.filter(event => event.category === 'user' && !event.agentId).map((event, index) => { + const li = element('li'); + const link = element('a', '', `${index + 1}. ${event.summary || 'User request'}`); + link.href = `#event-${encodeURIComponent(event.id)}`; + link.title = event.summary; + link.dataset.requestId = `event-${encodeURIComponent(event.id)}`; + li.append(link); + return li; + }); + $('requests').replaceChildren(...links); + $('status').textContent = `${snapshot.totalRecords.toLocaleString()} records · ${events.length.toLocaleString()} of ${snapshot.events.length.toLocaleString()} timeline events`; + $('session').textContent = snapshot.sessionId; + if (document.activeElement !== $('search') && $('search').value !== view.query) { + $('search').value = view.query; + } + for (const checkbox of $('filters').querySelectorAll('input')) { + checkbox.checked = view.categories.includes(checkbox.value); + } + $('follow').checked = view.follow; + $('announcements').checked = view.announcements; + $('collapse').disabled = view.expanded.length === 0; + $('status').setAttribute('aria-live', view.announcements ? 'polite' : 'off'); + findWorker.postMessage({ type: 'index', events }); + scheduleFind(); + updateStickyHeaders(); + if (focusedId) { + [...$('events').querySelectorAll('[data-event-id]')].find(node => node.dataset.eventId === focusedId)?.focus({ preventScroll: true }); + } + if (view.follow && !focusedId) { + window.scrollTo(0, document.body.scrollHeight); + } else { + window.scrollTo(0, scrollY); + } +} + +for (const category of categories) { + const label = element('label'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = category; + checkbox.checked = true; + label.append(checkbox, document.createTextNode(category[0].toUpperCase() + category.slice(1))); + $('filters').append(label); +} +$('filters').addEventListener('change', () => { + view.categories = [...$('filters').querySelectorAll('input:checked')].map(input => input.value); + save(); + if (snapshot) { + render(); + } +}); +$('search').addEventListener('input', () => { + view.query = $('search').value; + matchIndex = -1; + save(); + scheduleFind(); +}); +$('find-next').addEventListener('click', () => moveMatch(1)); +$('find-prev').addEventListener('click', () => moveMatch(-1)); +$('search').addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + moveMatch(event.shiftKey ? -1 : 1); + } +}); +window.addEventListener('scroll', updateActiveRequest, { passive: true }); +new ResizeObserver(() => updateStickyHeaders()).observe(document.querySelector('.toolbar')); +for (const id of ['follow', 'announcements']) { + $(id).addEventListener('change', () => { view[id] = $(id).checked; save(); }); +} +$('refresh').addEventListener('click', async () => { + $('refresh').disabled = true; + try { + await request('/api/refresh', {}); + revision = ''; + } catch (error) { + showError(error); + } finally { + $('refresh').disabled = false; + } +}); +$('collapse').addEventListener('click', () => { + view.expanded = []; + save(); + if (snapshot) { + render(); + } +}); +$('help').addEventListener('click', help); +$('text').addEventListener('click', textView); +$('dialog').addEventListener('close', () => { + if (document.activeElement === document.body || $('dialog').contains(document.activeElement)) { + restoreFocus?.focus(); + } +}); +document.addEventListener('keydown', event => { + if (event.altKey && ['F1', 'F2'].includes(event.key) && !$('dialog').open) { + event.preventDefault(); + (event.key === 'F1' ? help : textView)(); + } +}); +async function poll() { + try { + const response = await request(`/api/state?revision=${encodeURIComponent(revision)}`); + const finding = ['search', 'find-next', 'find-prev'].includes(document.activeElement?.id); + if (response.status !== 204) { + snapshot = await response.json(); + revision = snapshot.revision; + if (!editing) { + const query = view.query; + view = snapshot.view; + if (finding) { + view = { ...view, query }; + } + } + $('error').hidden = !snapshot.error; + $('error').textContent = snapshot.error; + if (finding) { + pendingRender = true; + } else { + pendingRender = false; + render(); + } + } else if (pendingRender && !finding) { + pendingRender = false; + render(); + } + } catch (error) { + showError(error); + } finally { + setTimeout(() => void poll(), 1000); + } +} +void poll(); diff --git a/session-timeline/extensions/session-timeline/server.mjs b/session-timeline/extensions/session-timeline/server.mjs new file mode 100644 index 0000000..b8c16be --- /dev/null +++ b/session-timeline/extensions/session-timeline/server.mjs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT license. + +import { createServer } from 'node:http'; +import { randomBytes } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { categories } from './model.mjs'; + +export function validateFilter(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) + || Object.keys(input).some(key => !['query', 'categories'].includes(key)) + || (input.query !== undefined && (typeof input.query !== 'string' || input.query.length > 2000)) + || (input.categories !== undefined && (!Array.isArray(input.categories) + || input.categories.length > categories.length || input.categories.some(category => !categories.includes(category))))) { + throw new Error('Expected query (up to 2000 characters) and/or an array of timeline categories.'); + } + return input; +} + +export async function createTimelineServer(source) { + const token = randomBytes(32).toString('hex'); + const cookieName = `timeline-${randomBytes(8).toString('hex')}`; + const assets = new Map(await Promise.all([ + ['/', 'index.html', 'text/html; charset=utf-8'], + ['/timeline.css', 'timeline.css', 'text/css; charset=utf-8'], + ['/timeline.mjs', 'timeline.mjs', 'text/javascript; charset=utf-8'], + ['/find-worker.mjs', 'find-worker.mjs', 'text/javascript; charset=utf-8'], + ['/model.mjs', '../model.mjs', 'text/javascript; charset=utf-8'], + ].map(async ([path, file, type]) => [path, { body: await readFile(new URL(`./public/${file}`, import.meta.url)), type }]))); + const view = { query: '', categories: [...categories], expanded: [], follow: false, announcements: true }; + let viewRevision = 0; + let origin; + const server = createServer((request, response) => { + void handle(request, response).catch(error => { + source.fail(error); + if (!response.headersSent) { + response.writeHead(500, { 'Content-Type': 'application/json' }); + } + response.end(JSON.stringify({ error: 'The timeline request failed. See the timeline status for details.' })); + }); + }); + function send(response, status, body, type = 'application/json; charset=utf-8') { + response.writeHead(status, { 'Content-Type': type }); + response.end(type.startsWith('application/json') ? JSON.stringify(body) : body); + } + async function handle(request, response) { + response.setHeader('Cache-Control', 'no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + response.setHeader('Referrer-Policy', 'no-referrer'); + response.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"); + if (request.headers.host !== new URL(origin).host) { + return send(response, 403, { error: 'Invalid host.' }); + } + const url = new URL(request.url, origin); + const asset = assets.get(url.pathname); + if (request.method === 'GET' && asset) { + if (url.pathname === '/') { + response.setHeader('Set-Cookie', `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/`); + } + return send(response, 200, asset.body, asset.type); + } + const authorized = request.headers.cookie?.split(';').some(cookie => cookie.trim() === `${cookieName}=${token}`); + if (!authorized || (request.headers.origin && request.headers.origin !== origin)) { + return send(response, 403, { error: 'Open the canvas before accessing its data.' }); + } + if (request.method === 'GET' && url.pathname === '/api/state') { + const revision = `${source.revision}:${viewRevision}`; + if (url.searchParams.get('revision') === revision) { + response.writeHead(204); + return response.end(); + } + return send(response, 200, { ...source.snapshot(), revision, view }); + } + if (request.method === 'GET' && url.pathname === '/api/raw') { + response.setHeader('Content-Disposition', 'attachment; filename="session-events.jsonl"'); + return send(response, 200, source.raw(), 'application/x-ndjson; charset=utf-8'); + } + if (request.method === 'POST' && ['/api/view', '/api/refresh'].includes(url.pathname)) { + if (request.headers.origin !== origin || request.headers['content-type'] !== 'application/json') { + return send(response, 403, { error: 'Expected a same-origin JSON request.' }); + } + let body = ''; + for await (const chunk of request) { + body += chunk; + if (Buffer.byteLength(body) > 65536) { + return send(response, 413, { error: 'Request is too large.' }); + } + } + let input; + try { + input = JSON.parse(body); + if (url.pathname === '/api/view') { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('Expected a view object.'); + } + validateFilter({ query: input.query, categories: input.categories }); + if (Object.keys(input).some(key => !Object.hasOwn(view, key)) + || (input.expanded !== undefined && (!Array.isArray(input.expanded) || input.expanded.length > 10000 || input.expanded.some(id => typeof id !== 'string' || id.length > 200))) + || ['follow', 'announcements'].some(key => input[key] !== undefined && typeof input[key] !== 'boolean')) { + throw new Error('Invalid timeline view state.'); + } + Object.assign(view, input); + viewRevision++; + } else { + if (!input || Object.keys(input).length) { + throw new Error('Refresh takes an empty object.'); + } + } + } catch (error) { + return send(response, 400, { error: error.message }); + } + if (url.pathname === '/api/refresh') { + await source.refresh(); + } + return send(response, 200, { ok: true }); + } + return send(response, 404, { error: 'Not found.' }); + } + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + origin = `http://127.0.0.1:${server.address().port}`; + return { + url: origin, + setFilter(input) { + Object.assign(view, validateFilter(input)); + viewRevision++; + return { query: view.query, categories: [...view.categories] }; + }, + close: () => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())), + }; +} diff --git a/session-timeline/extensions/session-timeline/source.mjs b/session-timeline/extensions/session-timeline/source.mjs new file mode 100644 index 0000000..0773124 --- /dev/null +++ b/session-timeline/extensions/session-timeline/source.mjs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT license. + +import { createTimelineModel, validateEvent } from './model.mjs'; + +export class TimelineSource { + constructor(session) { + this.session = session; + this.records = new Map(); + this.revision = 0; + this.error = ''; + this.buffer = []; + this.loading = false; + this.disposed = false; + this.model = { totalRecords: 0, events: [] }; + } + + async start() { + this.unsubscribe = this.session.on(event => { + try { + validateEvent(event); + if (event.ephemeral) { + return; + } + if (this.loading) { + this.buffer.push(event); + } else { + this.records.set(event.id, event); + this.scheduleBuild(); + } + if (['session.truncation', 'session.context_cleared'].includes(event.type)) { + clearTimeout(this.refreshTimer); + this.refreshTimer = setTimeout(() => void this.refresh().catch(error => this.fail(error)), 100); + } + } catch (error) { + this.fail(error); + } + }); + await this.refresh(); + } + + fail(error) { + const message = error instanceof Error ? error.message : String(error); + if (this.error === message) { + return; + } + this.error = message; + this.revision++; + void this.session.log(`Session timeline: ${this.error}`, { level: 'error', ephemeral: true }).catch(logError => { + process.stderr.write(`Session timeline logging failed: ${String(logError)}\n`); + }); + } + + scheduleBuild() { + if (!this.buildTimer) { + this.buildTimer = setTimeout(() => { + this.buildTimer = undefined; + try { + this.build(); + } catch (error) { + this.fail(error); + } + }, 100); + } + } + + build() { + this.model = createTimelineModel([...this.records.values()]); + this.revision++; + } + + refresh() { + if (this.pendingRefresh) { + return this.pendingRefresh; + } + this.pendingRefresh = this.load().finally(() => { this.pendingRefresh = undefined; }); + return this.pendingRefresh; + } + + async load() { + this.loading = true; + this.buffer = []; + try { + const events = await this.session.getEvents(); + if (this.disposed) { + return; + } + const records = new Map(events.map(event => [validateEvent(event).id, event])); + for (const event of this.buffer) { + records.set(event.id, event); + } + this.records = records; + this.error = ''; + this.build(); + } catch (error) { + for (const event of this.buffer) { + this.records.set(event.id, event); + } + this.build(); + this.fail(error); + throw error; + } finally { + this.loading = false; + this.buffer = []; + } + } + + snapshot() { + return { sessionId: this.session.sessionId, revision: this.revision, loading: this.loading, error: this.error, ...this.model }; + } + + raw() { + return [...this.records.values()].map(record => JSON.stringify(record)).join('\n') + '\n'; + } + + dispose() { + this.disposed = true; + this.unsubscribe?.(); + clearTimeout(this.buildTimer); + clearTimeout(this.refreshTimer); + } +} diff --git a/session-timeline/skills/session-timeline/SKILL.md b/session-timeline/skills/session-timeline/SKILL.md new file mode 100644 index 0000000..491c143 --- /dev/null +++ b/session-timeline/skills/session-timeline/SKILL.md @@ -0,0 +1,17 @@ +--- +name: session-timeline +description: 'Open a live timeline canvas for the current Copilot session. Use when asked to inspect session events, system prompts, reasoning, tool executions, or subagent activity in a session timeline.' +--- + +# Session timeline + +1. Inspect the capabilities of canvas `vscode-session-timeline`. +2. Open it with instance ID `session-timeline` and no input. Reuse that instance when asked to show it again. +3. Use `setFilter` to change the search text or visible categories when requested. Categories are `system`, `user`, `assistant`, `tool`, and `subagent`. An empty category list intentionally shows no events. +4. Use `refresh` to reload SDK history, or `getStatus` to inspect counts and loading errors. + +The canvas reads the joined session through the Copilot SDK, not a VS Code session directory. It updates automatically; do not repeatedly call refresh. Expanding an event shows its readable details and original SDK records. The user can download the SDK events as JSONL with **Export events**. + +System-prompt tool guidance describes prompt text, not an authoritative inventory of tools offered to the model. Reasoning and other optional fields are available only when the runtime emits them. History does not recover past ephemeral streaming events. + +If the canvas is unavailable, report that this plugin requires a Copilot host with canvas extensions enabled. Do not substitute a guessed filesystem path or install another copy of the extension. diff --git a/session-timeline/test/find-worker.test.mjs b/session-timeline/test/find-worker.test.mjs new file mode 100644 index 0000000..e8f0e2d --- /dev/null +++ b/session-timeline/test/find-worker.test.mjs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT license. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readFile } from 'node:fs/promises'; +import { runInNewContext } from 'node:vm'; + +const script = await readFile(new URL('../extensions/session-timeline/public/find-worker.mjs', import.meta.url), 'utf8'); + +function worker(clock = performance) { + const results = []; + const tasks = []; + const self = { postMessage: data => results.push(data) }; + runInNewContext(script, { self, performance: clock, setTimeout: resolve => tasks.push(resolve) }); + return { + results, tasks, + send: data => self.onmessage({ data }), + async flush() { + while (tasks.length) { + tasks.shift()(); + await Promise.resolve(); + } + }, + }; +} + +test('background Find counts section occurrences and falls back to raw records', async () => { + const w = worker(); + w.send({ type: 'index', events: [ + { id: 'readable', sections: [{ content: 'Needle needle' }], rawRecords: [{ content: 'needle' }] }, + { id: 'raw', sections: [{ content: 'other' }], rawRecords: [{ content: 'needle' }] }, + ] }); + w.send({ type: 'find', id: 1, query: 'NEEDLE' }); + await w.flush(); + assert.deepEqual(JSON.parse(JSON.stringify(w.results)), [{ + id: 1, query: 'NEEDLE', matches: [ + { id: 'readable', section: 0, occurrence: 0 }, + { id: 'readable', section: 0, occurrence: 1 }, + { id: 'raw', section: 1, occurrence: 0 }, + ], + }]); +}); + +test('cancels work at a yield and never publishes stale results', async () => { + let time = 0; + const w = worker({ now: () => time += 10 }); + w.send({ type: 'index', events: [{ id: 'large', sections: [{ content: 'needle '.repeat(1000) }], rawRecords: [] }] }); + w.send({ type: 'find', id: 1, query: 'needle' }); + assert.ok(w.tasks.length > 0); + w.send({ type: 'cancel' }); + await w.flush(); + assert.equal(w.results.length, 0); + w.send({ type: 'find', id: 2, query: 'absent' }); + await w.flush(); + assert.equal(w.results[0].id, 2); + assert.equal(w.results[0].matches.length, 0); +}); diff --git a/session-timeline/test/model.test.mjs b/session-timeline/test/model.test.mjs new file mode 100644 index 0000000..c33512a --- /dev/null +++ b/session-timeline/test/model.test.mjs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT license. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createTimelineModel, selectEvents, sectionOutlines } from '../extensions/session-timeline/model.mjs'; + +export function record(id, type, data, agentId) { + return { id, type, data, parentId: null, timestamp: '2026-10-21T12:00:00.000Z', ...(agentId ? { agentId } : {}) }; +} + +test('groups turns and pairs tool results by semantic ownership, retaining raw data', () => { + const records = [ + record('u', 'user.message', { content: 'Request', transformedContent: 'Transformed', attachments: [{ path: '/test' }] }), + record('s', 'system.message', { content: 'System' }), + record('a', 'assistant.message', { content: 'Response', reasoningText: 'Reasoning', toolRequests: [{ toolCallId: 'call', name: 'read' }] }), + record('t', 'tool.execution_start', { toolCallId: 'call', toolName: 'read', arguments: { path: '/test' } }), + { ...record('c', 'tool.execution_complete', { toolCallId: 'call', success: true, result: { content: 'Result' } }), timestamp: '2026-10-21T12:00:01.250Z' }, + ]; + const model = createTimelineModel(records); + assert.deepEqual({ + total: model.totalRecords, + events: model.events.map(({ id, parentEventId, category, title, summary, status, duration, rawRecords }) => ({ id, parentEventId, category, title, summary, status, duration, rawIds: rawRecords.map(r => r.id) })), + reasoning: model.events[2].sections.find(s => s.label === 'Reasoning').content, + }, { + total: 5, + events: [ + { id: 'u', parentEventId: undefined, category: 'user', title: 'User', summary: 'Request', status: '', duration: undefined, rawIds: ['u'] }, + { id: 's', parentEventId: 'u', category: 'system', title: 'System', summary: 'System', status: '', duration: undefined, rawIds: ['s'] }, + { id: 'a', parentEventId: 's', category: 'assistant', title: 'Assistant', summary: 'Response', status: '', duration: undefined, rawIds: ['a'] }, + { id: 't', parentEventId: 'a', category: 'tool', title: 'read', summary: 'path: /test', status: 'Completed', duration: 1250, rawIds: ['t', 'c'] }, + ], + reasoning: 'Reasoning', + }); +}); + +test('correlates concurrent subagent calls without mixing completions', () => { + const model = createTimelineModel([ + record('u', 'user.message', { content: 'Request' }), + record('a', 'assistant.message', { content: 'Start agents' }), + record('spawn', 'tool.execution_start', { toolCallId: 'spawn-call', toolName: 'task' }), + record('sub', 'subagent.started', { toolCallId: 'spawn-call', agentDisplayName: 'Explorer' }), + record('ca', 'assistant.message', { content: 'Child', parentToolCallId: 'spawn-call' }, 'child'), + record('ct', 'tool.execution_start', { toolCallId: 'duplicate', toolName: 'read', parentToolCallId: 'spawn-call' }, 'child'), + record('ot', 'tool.execution_start', { toolCallId: 'duplicate', toolName: 'read' }, 'other-child'), + record('cc', 'tool.execution_complete', { toolCallId: 'duplicate', success: false, error: { message: 'failed' } }, 'child'), + record('oc', 'tool.execution_complete', { toolCallId: 'duplicate', success: true, result: 'passed' }, 'other-child'), + record('sc', 'subagent.completed', { toolCallId: 'spawn-call' }), + ]); + assert.deepEqual(model.events.filter(e => ['sub', 'ca', 'ct', 'ot'].includes(e.id)).map(e => ({ + id: e.id, parent: e.parentEventId, status: e.status, raw: e.rawRecords.map(r => r.id), + })), [ + { id: 'sub', parent: 'spawn', status: 'Completed', raw: ['sub', 'sc'] }, + { id: 'ca', parent: 'spawn', status: '', raw: ['ca'] }, + { id: 'ct', parent: 'spawn', status: 'Failed', raw: ['ct', 'cc'] }, + { id: 'ot', parent: undefined, status: 'Completed', raw: ['ot', 'oc'] }, + ]); +}); + +test('pairs external tools, reports failures and preserves unmatched completions', () => { + const model = createTimelineModel([ + record('x', 'external_tool.requested', { requestId: 'r', toolName: 'external', arguments: { query: 'needle' } }), + record('y', 'external_tool.completed', { requestId: 'r', success: false, error: 'broken' }), + record('z', 'subagent.failed', { toolCallId: 'unknown', error: 'stopped' }), + ]); + assert.deepEqual(model.events.map(e => [e.title, e.status, e.sections.at(-1).content]), [ + ['external', 'Failed', 'broken'], + ['Subagent failed', '', '{\n "toolCallId": "unknown",\n "error": "stopped"\n}'], + ]); +}); + +test('summarizes tool arguments with readable paths and descriptions instead of collapsed JSON', () => { + const model = createTimelineModel([ + record('t', 'tool.execution_start', { + toolCallId: 'call', toolName: 'view', + arguments: { description: 'Inspect the component', path: '/src/component.ts', content: 'large content' }, + }), + ]); + assert.equal(model.events[0].summary, 'description: Inspect the component · path: /src/component.ts'); +}); + +test('omits placeholder tool previews while preserving full arguments', () => { + const args = { title: 'New chat title', automatic: true }; + const { events } = createTimelineModel([ + record('t', 'tool.execution_start', { toolCallId: 'call', toolName: 'rename_chat', arguments: args }), + ]); + assert.equal(events[0].summary, ''); + assert.deepEqual(JSON.parse(events[0].sections[0].content), args); +}); + +test('extracts prompt capabilities and top-level instructions without treating tool tags as instructions', () => { + const content = 'reviewRun commandsSearch filesBe precise'; + const model = createTimelineModel([record('s', 'system.message', { content })]); + assert.deepEqual(model.events[0].sections.map(s => s.label), [ + 'Advertised skills', 'Tool guidance: bash', 'Tool guidance: rg', 'Instruction: code change instructions', 'Content', + ]); +}); + +test('search and categories retain context ancestors, and empty categories show nothing', () => { + const model = createTimelineModel([ + record('u', 'user.message', { content: 'Request' }), + record('a', 'assistant.message', { content: 'Response' }), + record('t', 'tool.execution_start', { toolCallId: 'call', toolName: 'read', arguments: { path: 'Needle' } }), + ]); + assert.deepEqual([ + selectEvents(model.events, 'NEEDLE', ['tool']).map(e => e.id), + selectEvents(model.events, '', []).map(e => e.id), + selectEvents(model.events, 'absent', ['tool']).map(e => e.id), + ], [['u', 'a', 't'], [], []]); +}); + +test('rejects invalid event envelopes explicitly', () => { + assert.throws(() => createTimelineModel([{ type: 'user.message', data: {} }]), /invalid session event/); +}); + +test('section pills group prompt sections and link to the first section in each group', () => { + const outlines = sectionOutlines([ + { label: 'Advertised skills', content: 'one\ntwo' }, + { label: 'Tool guidance: bash', content: 'Run commands' }, + { label: 'Tool guidance: view', content: 'Read files' }, + { label: 'Instruction: first', content: 'First rule' }, + { label: 'Instruction: second', content: 'Second rule' }, + { label: 'Content', content: 'a'.repeat(1500) }, + ]); + assert.deepEqual(outlines.map(({ text, index }) => [text, index]), [ + ['Advertised skills 2', 0], ['Tool guidance 2', 1], ['Instructions 2', 3], + [`Content ${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(1.5)}kB`, 5], + ]); +}); + +test('assistant pills show actual tool count, model name, and UTF-8 response size', () => { + const { events } = createTimelineModel([record('a', 'assistant.message', { + content: '\u00e9', model: 'example-model', + toolRequests: [{ toolCallId: 'one', name: 'view' }, { toolCallId: 'two', name: 'bash' }], + })]); + assert.deepEqual(sectionOutlines(events[0].sections).map(item => item.text), [ + 'Response 2B', 'Tool requests 2', 'example-model', + ]); + const toolsOnly = createTimelineModel([record('b', 'assistant.message', { toolRequests: [{ toolCallId: 'one', name: 'view' }] })]); + assert.equal(toolsOnly.events[0].summary, ''); +}); diff --git a/session-timeline/test/server.test.mjs b/session-timeline/test/server.test.mjs new file mode 100644 index 0000000..7145f66 --- /dev/null +++ b/session-timeline/test/server.test.mjs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT license. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readFile } from 'node:fs/promises'; +import { createTimelineServer } from '../extensions/session-timeline/server.mjs'; + +async function fixture(t) { + const source = { + revision: 1, + snapshot: () => ({ sessionId: 'fixture', totalRecords: 1, events: [], error: '' }), + raw: () => '{"type":"user.message","data":{"content":"fixture"}}\n', + refresh: async () => { source.revision++; }, + fail: error => { throw error; }, + }; + const server = await createTimelineServer(source); + t.after(() => server.close()); + const page = await fetch(server.url); + const cookie = page.headers.get('set-cookie').split(';')[0]; + const get = path => fetch(server.url + path, { headers: { Cookie: cookie } }); + const post = (path, body, origin = server.url) => fetch(server.url + path, { + method: 'POST', headers: { Cookie: cookie, Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }); + return { server, page, get, post }; +} + +test('serves canvas assets with CSP, requires a cookie for private data, and rejects foreign origins', async t => { + const { server, page, get, post } = await fixture(t); + const unauthenticated = await fetch(server.url + '/api/state'); + const state = await get('/api/state'); + const worker = await fetch(server.url + '/find-worker.mjs'); + assert.equal(worker.status, 200); + assert.match(worker.headers.get('content-type'), /javascript/); + const foreign = await post('/api/view', { query: 'secret' }, 'https://example.com'); + assert.deepEqual([page.status, page.headers.get('content-security-policy').includes("script-src 'self'"), unauthenticated.status, state.status, foreign.status], [200, true, 403, 200, 403]); +}); + +test('preserves view state, applies action filters, and returns 204 for unchanged revisions', async t => { + const { server, get, post } = await fixture(t); + await post('/api/view', { query: 'needle', categories: ['tool'], expanded: ['example'], follow: true }); + const initial = await (await get('/api/state')).json(); + const unchanged = await get(`/api/state?revision=${encodeURIComponent(initial.revision)}`); + server.setFilter({ query: 'changed' }); + const updated = await (await get('/api/state')).json(); + assert.deepEqual([initial.view, unchanged.status, updated.view], [ + { query: 'needle', categories: ['tool'], expanded: ['example'], follow: true, announcements: true }, + 204, + { query: 'changed', categories: ['tool'], expanded: ['example'], follow: true, announcements: true }, + ]); +}); + +test('validates inputs and exports raw SDK records as a download', async t => { + const { get, post, server } = await fixture(t); + const invalid = await post('/api/view', { categories: ['invalid'] }); + const oversized = await post('/api/view', { query: 'x'.repeat(2001) }); + const unknown = await post('/api/view', { arbitrary: true }); + const raw = await get('/api/raw'); + assert.throws(() => server.setFilter({ categories: 'tool' }), /Expected query/); + assert.deepEqual([invalid.status, oversized.status, unknown.status, raw.headers.get('content-disposition'), await raw.text()], [ + 400, 400, 400, 'attachment; filename="session-events.jsonl"', '{"type":"user.message","data":{"content":"fixture"}}\n', + ]); +}); + +test('manual refresh changes revision while preserving filters', async t => { + const { get, post } = await fixture(t); + await post('/api/view', { query: 'keep' }); + const before = await (await get('/api/state')).json(); + await post('/api/refresh', {}); + const after = await (await get('/api/state')).json(); + assert.deepEqual([before.revision !== after.revision, after.view.query], [true, 'keep']); +}); + +test('plugin manifest and marketplace registration agree', async () => { + const root = new URL('../../', import.meta.url); + const manifest = JSON.parse(await readFile(new URL('session-timeline/.plugin/plugin.json', root), 'utf8')); + const marketplace = JSON.parse(await readFile(new URL('marketplace.json', root), 'utf8')); + const entry = marketplace.plugins.find(plugin => plugin.name === manifest.name); + assert.deepEqual([entry.version, entry.description, manifest.extensions], [manifest.version, manifest.description, 'extensions/']); +}); diff --git a/session-timeline/test/source.test.mjs b/session-timeline/test/source.test.mjs new file mode 100644 index 0000000..a5cb24f --- /dev/null +++ b/session-timeline/test/source.test.mjs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT license. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { setTimeout } from 'node:timers/promises'; +import { TimelineSource } from '../extensions/session-timeline/source.mjs'; + +const event = (id, type = 'user.message', data = { content: id }) => ({ id, type, data, timestamp: '2026-10-21T12:00:00Z', parentId: null }); + +function fixture(history) { + let handler; + const logs = []; + const session = { + sessionId: 'fixture-session', + getEvents: async () => history, + on(callback) { handler = callback; return () => { handler = undefined; }; }, + log: async message => { logs.push(message); }, + }; + const source = new TimelineSource(session); + return { source, session, logs, emit: record => handler?.(record), subscribed: () => !!handler }; +} + +test('subscribes before fetching, merging buffered events and deduplicating by stable ID', async t => { + const fixtureSource = fixture([]); + const { source, session, emit } = fixtureSource; + t.after(() => source.dispose()); + session.getEvents = async () => { + assert.equal(fixtureSource.subscribed(), true); + emit(event('a')); + emit(event('b')); + return [event('a')]; + }; + await source.start(); + assert.deepEqual([source.snapshot().totalRecords, [...source.records.keys()]], [2, ['a', 'b']]); +}); + +test('updates live, ignores ephemeral records, and disposes the subscription', async () => { + const { source, emit, subscribed } = fixture([event('a')]); + await source.start(); + emit({ ...event('delta', 'assistant.message_delta'), ephemeral: true }); + emit(event('b', 'assistant.message', { content: 'Reply' })); + await setTimeout(150); + const result = [source.snapshot().totalRecords, source.snapshot().events.map(e => e.id)]; + source.dispose(); + assert.deepEqual([result, subscribed()], [[2, ['a', 'b']], false]); +}); + +test('surfaces a failed refresh while retaining events arriving during the request', async t => { + const { source, session, emit, logs } = fixture([event('a')]); + t.after(() => source.dispose()); + await source.start(); + session.getEvents = async () => { + emit(event('b')); + throw new Error('History unavailable'); + }; + await assert.rejects(source.refresh(), /History unavailable/); + assert.deepEqual([source.snapshot().totalRecords, source.snapshot().error, logs], [2, 'History unavailable', ['Session timeline: History unavailable']]); +}); + +test('replaces removed history on truncation and recovers from errors', async t => { + const { source, session, emit } = fixture([event('a'), event('b')]); + t.after(() => source.dispose()); + await source.start(); + session.getEvents = async () => [event('a')]; + emit(event('truncated', 'session.truncation', {})); + await setTimeout(250); + assert.deepEqual([...source.records.keys()], ['a']); +}); + +test('reports invalid live records without inserting them', async t => { + const { source, emit } = fixture([]); + t.after(() => source.dispose()); + await source.start(); + emit({ type: 'broken' }); + assert.deepEqual([source.snapshot().totalRecords, source.snapshot().error], [0, 'The SDK returned an invalid session event envelope.']); +});