diff --git a/packages/vscode/src/sessionActivityWatcher.ts b/packages/vscode/src/sessionActivityWatcher.ts index 48bc8696..9d3fda91 100644 --- a/packages/vscode/src/sessionActivityWatcher.ts +++ b/packages/vscode/src/sessionActivityWatcher.ts @@ -16,6 +16,38 @@ const SESSION_COOLDOWN_DURATION_MS = 2000; let globalEventWatcherAbortController: AbortController | null = null; let chatViewProvider: { postMessage: (message: unknown) => void } | null = null; +const reconcileSessionActivityFromStatus = async (manager: OpenCodeManager): Promise => { + const baseUrl = manager.getApiUrl(); + if (!baseUrl) { + return; + } + + const url = new URL('/session/status', baseUrl); + const response = await fetch(url.toString(), { + headers: manager.getOpenCodeAuthHeaders(), + }); + + if (!response.ok) { + throw new Error(`session status fetch failed (${response.status})`); + } + + const statuses = await response.json() as Record; + const knownSessionIds = new Set(Object.keys(statuses || {})); + + for (const [sessionId, data] of Object.entries(statuses || {})) { + const type = typeof data?.type === 'string' ? data.type : 'idle'; + const phase: ActivityPhase = type === 'busy' || type === 'retry' ? 'busy' : 'idle'; + setSessionActivityPhase(sessionId, phase); + } + + // Drop stale in-memory activity entries not present in authoritative status. + for (const sessionId of Array.from(sessionActivityPhases.keys())) { + if (!knownSessionIds.has(sessionId)) { + setSessionActivityPhase(sessionId, 'idle'); + } + } +}; + const setSessionActivityPhase = (sessionId: string, phase: ActivityPhase): void => { if (!sessionId) return; @@ -82,8 +114,9 @@ const deriveSessionActivity = (payload: Record): SessionActivit if (type === 'session.status') { const status = properties?.status as Record | undefined; + const info = properties?.info as Record | undefined; const sessionId = (properties?.sessionID ?? properties?.sessionId) as string; - const statusType = status?.type as string; + const statusType = (status?.type ?? info?.type) as string; if (typeof sessionId === 'string' && sessionId.length > 0 && typeof statusType === 'string') { const phase = statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle'; @@ -166,6 +199,14 @@ export const startGlobalEventWatcher = async ( baseUrl, headers: manager.getOpenCodeAuthHeaders(), }); + try { + await reconcileSessionActivityFromStatus(manager); + } catch (error) { + console.warn( + '[VSCode:Activity] session status reconcile failed', + error instanceof Error ? error.message : error, + ); + } const result = await client.global.event({ signal, sseMaxRetryAttempts: 0, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 945f369e..fef16793 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -716,9 +716,12 @@ const processForwardedEventPayload = (payload, emitSyntheticEvent) => { } const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {}; + const statusInfo = properties.status && typeof properties.status === 'object' ? properties.status : {}; const info = properties.info && typeof properties.info === 'object' ? properties.info : {}; const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : ''; - const status = typeof info.type === 'string' ? info.type.trim() : ''; + const status = typeof statusInfo.type === 'string' + ? statusInfo.type.trim() + : (typeof info.type === 'string' ? info.type.trim() : ''); if (!sessionId || !status) { return; @@ -731,9 +734,15 @@ const processForwardedEventPayload = (payload, emitSyntheticEvent) => { status, timestamp: Date.now(), metadata: { - attempt: typeof info.attempt === 'number' ? info.attempt : undefined, - message: typeof info.message === 'string' ? info.message : undefined, - next: typeof info.next === 'number' ? info.next : undefined, + attempt: typeof statusInfo.attempt === 'number' + ? statusInfo.attempt + : (typeof info.attempt === 'number' ? info.attempt : undefined), + message: typeof statusInfo.message === 'string' + ? statusInfo.message + : (typeof info.message === 'string' ? info.message : undefined), + next: typeof statusInfo.next === 'number' + ? statusInfo.next + : (typeof info.next === 'number' ? info.next : undefined), }, needsAttention: false, }, diff --git a/packages/web/server/lib/opencode/session-runtime.js b/packages/web/server/lib/opencode/session-runtime.js index 48a4c9ad..6bcbbec4 100644 --- a/packages/web/server/lib/opencode/session-runtime.js +++ b/packages/web/server/lib/opencode/session-runtime.js @@ -9,9 +9,13 @@ const extractSessionStatusUpdate = (payload) => { } const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {}; + const status = properties.status && typeof properties.status === 'object' ? properties.status : {}; const info = properties.info && typeof properties.info === 'object' ? properties.info : {}; const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : ''; - const type = typeof info.type === 'string' ? info.type.trim() : ''; + // Canonical OpenCode schema uses properties.status.type. Keep legacy info.type fallback for compatibility. + const type = typeof status.type === 'string' + ? status.type.trim() + : (typeof info.type === 'string' ? info.type.trim() : ''); if (!sessionId || !type) { return null; @@ -21,9 +25,15 @@ const extractSessionStatusUpdate = (payload) => { sessionId, type, eventId: typeof payload.id === 'string' ? payload.id : '', - attempt: typeof info.attempt === 'number' ? info.attempt : undefined, - message: typeof info.message === 'string' ? info.message : undefined, - next: typeof info.next === 'number' ? info.next : undefined, + attempt: typeof status.attempt === 'number' + ? status.attempt + : (typeof info.attempt === 'number' ? info.attempt : undefined), + message: typeof status.message === 'string' + ? status.message + : (typeof info.message === 'string' ? info.message : undefined), + next: typeof status.next === 'number' + ? status.next + : (typeof info.next === 'number' ? info.next : undefined), }; }; diff --git a/packages/web/server/lib/opencode/session-runtime.test.js b/packages/web/server/lib/opencode/session-runtime.test.js index 4c4e0105..e0988864 100644 --- a/packages/web/server/lib/opencode/session-runtime.test.js +++ b/packages/web/server/lib/opencode/session-runtime.test.js @@ -29,7 +29,7 @@ describe('session runtime', () => { type: 'session.status', properties: { sessionID: 'session-1', - info: { + status: { type: 'busy', }, }, @@ -39,7 +39,7 @@ describe('session runtime', () => { type: 'session.status', properties: { sessionID: 'session-1', - info: { + status: { type: 'idle', }, }, @@ -65,4 +65,36 @@ describe('session runtime', () => { }, }); }); + + it('accepts legacy session.status info.type payloads', () => { + const events = []; + const runtime = createSessionRuntime({ + writeSseEvent() { + throw new Error('SSE fallback should not be used when broadcastEvent is provided'); + }, + getNotificationClients: () => new Set(), + broadcastEvent: (payload) => { + events.push(payload); + }, + }); + runtimes.push(runtime); + + runtime.processOpenCodeSsePayload({ + type: 'session.status', + properties: { + sessionID: 'legacy-session-1', + info: { + type: 'busy', + }, + }, + }); + + expect(events).toContainEqual({ + type: 'openchamber:session-status', + properties: expect.objectContaining({ + sessionId: 'legacy-session-1', + status: 'busy', + }), + }); + }); });