feat(event-stream): add global event stream and activity tracking

Add global event stream endpoint for cross-session activity monitoring
Support multiple concurrent SSE subscriptions with scoped connections
Enhance session activity detection from various event types
This commit is contained in:
Bohdan Triapitsyn
2026-01-04 10:50:35 +02:00
parent 9d1aceeb6d
commit 45b3d1d920
5 changed files with 686 additions and 53 deletions
+125 -4
View File
@@ -177,6 +177,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
const { path, headers } = (payload || {}) as { path?: string; headers?: Record<string, string> };
const normalizedPath = typeof path === 'string' && path.trim().length > 0 ? path.trim() : '/event';
const shouldInjectActivity = normalizedPath === '/event' || normalizedPath === '/global/event';
if (!apiBaseUrl) {
return {
@@ -246,11 +247,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
// Reduce webview message pressure by forwarding complete SSE blocks.
// The SDK SSE parser is block-based (\n\n delimited) and can consume
// partial chunks, but VS Code's postMessage channel can be a bottleneck.
sseBuffer += chunk;
sseBuffer += shouldInjectActivity ? chunk.replace(/\r\n/g, '\n') : chunk;
const blocks = sseBuffer.split('\n\n');
sseBuffer = blocks.pop() ?? '';
if (blocks.length > 0) {
const joined = blocks.map((block) => `${block}\n\n`).join('');
const outboundBlocks = shouldInjectActivity ? expandSseBlocksWithActivity(blocks) : blocks;
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
}
}
@@ -258,10 +260,16 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
const tail = decoder.decode();
if (tail) {
sseBuffer += tail;
sseBuffer += shouldInjectActivity ? tail.replace(/\r\n/g, '\n') : tail;
}
if (sseBuffer) {
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
if (shouldInjectActivity) {
const outboundBlocks = expandSseBlocksWithActivity([sseBuffer]);
const joined = outboundBlocks.map((block: string) => `${block}\n\n`).join('');
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: joined });
} else {
this._view?.webview.postMessage({ type: 'api:sse:chunk', streamId, chunk: sseBuffer });
}
}
} finally {
try {
@@ -322,3 +330,116 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
});
}
}
type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
type SessionActivity = {
sessionId: string;
phase: SessionActivityPhase;
};
const parseSseDataPayload = (block: string): Record<string, unknown> | null => {
if (!block) {
return null;
}
const dataLines = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^\s/, ''));
if (dataLines.length === 0) {
return null;
}
const payloadText = dataLines.join('\n').trim();
if (!payloadText) {
return null;
}
try {
const parsed = JSON.parse(payloadText) as unknown;
if (parsed && typeof parsed === 'object') {
const record = parsed as Record<string, unknown>;
const nestedPayload = record.payload;
if (nestedPayload && typeof nestedPayload === 'object') {
return nestedPayload as Record<string, unknown>;
}
return record;
}
return null;
} catch {
return null;
}
};
const deriveSessionActivity = (payload: Record<string, unknown> | null): SessionActivity | null => {
if (!payload || typeof payload !== 'object') {
return null;
}
const type = payload.type;
const properties = payload.properties as Record<string, unknown> | undefined;
if (type === 'session.status') {
const status = properties?.status as Record<string, unknown> | undefined;
const sessionId = properties?.sessionID ?? properties?.sessionId;
const statusType = status?.type;
if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') {
const phase: SessionActivityPhase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle';
return { sessionId, phase };
}
}
if (type === 'message.updated') {
const info = properties?.info as Record<string, unknown> | undefined;
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
const role = info?.role;
const finish = info?.finish;
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
return { sessionId, phase: 'cooldown' };
}
}
if (type === 'message.part.updated') {
const info = properties?.info as Record<string, unknown> | undefined;
const sessionId = info?.sessionID ?? info?.sessionId ?? properties?.sessionID ?? properties?.sessionId;
const role = info?.role;
const finish = info?.finish;
if (typeof sessionId === 'string' && sessionId.length > 0 && role === 'assistant' && finish === 'stop') {
return { sessionId, phase: 'cooldown' };
}
}
if (type === 'session.idle') {
const sessionId = properties?.sessionID ?? properties?.sessionId;
if (typeof sessionId === 'string' && sessionId.length > 0) {
return { sessionId, phase: 'idle' };
}
}
return null;
};
const buildActivityEventBlock = (activity: SessionActivity): string => {
return `data: ${JSON.stringify({
type: 'openchamber:session-activity',
properties: {
sessionId: activity.sessionId,
phase: activity.phase,
},
})}`;
};
const expandSseBlocksWithActivity = (blocks: string[]): string[] => {
const expanded: string[] = [];
for (const block of blocks) {
expanded.push(block);
const activity = deriveSessionActivity(parseSseDataPayload(block));
if (activity) {
expanded.push(buildActivityEventBlock(activity));
}
}
return expanded;
};