Track session status via SSE updates

This commit is contained in:
Shantur Rathore
2026-01-02 19:24:37 +00:00
parent abb8a9df19
commit a041e1c6c3
7 changed files with 160 additions and 13 deletions

View File

@@ -13,6 +13,7 @@ import type {
EventSessionError, EventSessionError,
EventSessionIdle, EventSessionIdle,
EventSessionUpdated, EventSessionUpdated,
EventSessionStatus,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import { serverEvents } from "./server-events" import { serverEvents } from "./server-events"
import type { import type {
@@ -134,6 +135,9 @@ class SSEManager {
case "session.idle": case "session.idle":
this.onSessionIdle?.(instanceId, event as EventSessionIdle) this.onSessionIdle?.(instanceId, event as EventSessionIdle)
break break
case "session.status":
this.onSessionStatus?.(instanceId, event as EventSessionStatus)
break
case "permission.updated": case "permission.updated":
this.onPermissionUpdated?.(instanceId, event as EventPermissionUpdated) this.onPermissionUpdated?.(instanceId, event as EventPermissionUpdated)
break break
@@ -171,6 +175,7 @@ class SSEManager {
onSessionError?: (instanceId: string, event: EventSessionError) => void onSessionError?: (instanceId: string, event: EventSessionError) => void
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void

View File

@@ -19,6 +19,7 @@ import {
setProviders, setProviders,
setSessionInfoByInstance, setSessionInfoByInstance,
setSessions, setSessions,
setSessionStatus,
sessions, sessions,
loading, loading,
setLoading, setLoading,
@@ -27,6 +28,7 @@ import {
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
import { normalizeMessagePart } from "./message-v2/normalizers" import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
import { deriveSessionStatusFromMessages } from "./session-status"
import { seedSessionMessagesV2 } from "./message-v2/bridge" import { seedSessionMessagesV2 } from "./message-v2/bridge"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
import { clearCacheForSession } from "../lib/global-cache" import { clearCacheForSession } from "../lib/global-cache"
@@ -82,6 +84,10 @@ async function fetchSessions(instanceId: string): Promise<void> {
for (const apiSession of response.data) { for (const apiSession of response.data) {
const existingSession = existingSessions?.get(apiSession.id) const existingSession = existingSessions?.get(apiSession.id)
const compactingFlag = (apiSession.time as (Session["time"] & { compacting?: number | boolean }) | undefined)?.compacting
const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag)
const existingStatus = existingSession?.status
sessionMap.set(apiSession.id, { sessionMap.set(apiSession.id, {
id: apiSession.id, id: apiSession.id,
instanceId, instanceId,
@@ -89,6 +95,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
parentId: apiSession.parentID || null, parentId: apiSession.parentID || null,
agent: existingSession?.agent ?? "", agent: existingSession?.agent ?? "",
model: existingSession?.model ?? { providerId: "", modelId: "" }, model: existingSession?.model ?? { providerId: "", modelId: "" },
status: isCompacting ? "compacting" : (existingStatus ?? "idle"),
version: apiSession.version, version: apiSession.version,
time: { time: {
...apiSession.time, ...apiSession.time,
@@ -183,6 +190,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
parentId: null, parentId: null,
agent: selectedAgent, agent: selectedAgent,
model: defaultModel, model: defaultModel,
status: "idle",
version: response.data.version, version: response.data.version,
time: { time: {
...response.data.time, ...response.data.time,
@@ -290,6 +298,7 @@ async function forkSession(
providerId: info.model?.providerID || "", providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "", modelId: info.model?.modelID || "",
}, },
status: "idle",
version: "0", version: "0",
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() }, time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
revert: info.revert revert: info.revert
@@ -606,6 +615,11 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
} }
seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo) seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo)
if (!alreadyLoaded) {
const nextStatus = deriveSessionStatusFromMessages(instanceId, sessionId)
setSessionStatus(instanceId, sessionId, nextStatus)
}
} catch (error) { } catch (error) {
log.error("Failed to load messages:", error) log.error("Failed to load messages:", error)
throw error throw error

View File

@@ -12,6 +12,7 @@ import type {
EventSessionError, EventSessionError,
EventSessionIdle, EventSessionIdle,
EventSessionUpdated, EventSessionUpdated,
EventSessionStatus,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import type { MessageStatus } from "./message-v2/types" import type { MessageStatus } from "./message-v2/types"
@@ -19,11 +20,11 @@ import { getLogger } from "../lib/logger"
import { showToastNotification, ToastVariant } from "../lib/notifications" import { showToastNotification, ToastVariant } from "../lib/notifications"
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances" import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
import { showAlertDialog } from "./alerts" import { showAlertDialog } from "./alerts"
import { createClientSession, Session, SessionStatus } from "../types/session"
import { sessions, setSessions, withSession } from "./session-state" import { sessions, setSessions, withSession } from "./session-state"
import { normalizeMessagePart } from "./message-v2/normalizers" import { normalizeMessagePart } from "./message-v2/normalizers"
import { updateSessionInfo } from "./message-v2/session-info" import { updateSessionInfo } from "./message-v2/session-info"
const log = getLogger("sse")
import { loadMessages } from "./session-api" import { loadMessages } from "./session-api"
import { setSessionCompactionState } from "./session-compaction" import { setSessionCompactionState } from "./session-compaction"
import { import {
@@ -39,6 +40,9 @@ import {
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
import type { InstanceMessageStore } from "./message-v2/instance-store" import type { InstanceMessageStore } from "./message-v2/instance-store"
const log = getLogger("sse")
const pendingSessionFetches = new Map<string, Promise<void>>()
interface TuiToastEvent { interface TuiToastEvent {
type: "tui.toast.show" type: "tui.toast.show"
properties: { properties: {
@@ -51,8 +55,83 @@ interface TuiToastEvent {
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"]) const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
const mapSdkSessionStatus = (status: EventSessionStatus["properties"]["status"]): SessionStatus => {
if (!status || status.type === "idle") {
return "idle"
}
if (status.type === "retry") {
return "working"
}
return "working"
}
function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus, bumpUpdated = false) {
withSession(instanceId, sessionId, (session) => {
session.status = status
if (bumpUpdated) {
session.time = { ...(session.time ?? {}), updated: Date.now() }
}
})
}
async function fetchSessionInfo(instanceId: string, sessionId: string): Promise<Session | null> {
const instance = instances().get(instanceId)
if (!instance?.client) return null
try {
const response = await instance.client.session.get({ path: { id: sessionId } })
if (!response.data) return null
const fetched = createClientSession(response.data, instanceId)
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(next.get(instanceId) ?? [])
const existing = instanceSessions.get(sessionId)
instanceSessions.set(sessionId, {
...fetched,
agent: existing?.agent ?? fetched.agent,
model: existing?.model ?? fetched.model,
status: existing?.status ?? fetched.status,
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
})
next.set(instanceId, instanceSessions)
return next
})
return fetched
} catch (error) {
log.error("Failed to fetch session info", error)
return null
}
}
function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus, bumpUpdated = false) {
const instanceSessions = sessions().get(instanceId)
const existing = instanceSessions?.get(sessionId)
if (existing) {
applySessionStatus(instanceId, sessionId, status, bumpUpdated)
return
}
const key = `${instanceId}:${sessionId}`
if (pendingSessionFetches.has(key)) {
return
}
const pending = (async () => {
const fetched = await fetchSessionInfo(instanceId, sessionId)
if (!fetched) return
applySessionStatus(instanceId, sessionId, status, bumpUpdated)
})()
pendingSessionFetches.set(key, pending)
void pending.finally(() => pendingSessionFetches.delete(key))
}
type MessageRole = "user" | "assistant" type MessageRole = "user" | "assistant"
function resolveMessageRole(info?: MessageInfo | null): MessageRole { function resolveMessageRole(info?: MessageInfo | null): MessageRole {
return info?.role === "user" ? "user" : "assistant" return info?.role === "user" ? "user" : "assistant"
} }
@@ -74,7 +153,6 @@ function findPendingMessageId(
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void { function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
if (event.type === "message.part.updated") { if (event.type === "message.part.updated") {
const rawPart = event.properties?.part const rawPart = event.properties?.part
@@ -90,8 +168,13 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
if (!sessionId || !messageId) return if (!sessionId || !messageId) return
const session = instanceSessions.get(sessionId) const session = instanceSessions?.get(sessionId)
if (!session) return if (!session) {
ensureSessionStatus(instanceId, sessionId, "working", true)
return
}
applySessionStatus(instanceId, sessionId, "working", true)
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)
const role: MessageRole = resolveMessageRole(messageInfo) const role: MessageRole = resolveMessageRole(messageInfo)
@@ -135,10 +218,16 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const messageId = typeof info.id === "string" ? info.id : undefined const messageId = typeof info.id === "string" ? info.id : undefined
if (!sessionId || !messageId) return if (!sessionId || !messageId) return
const session = instanceSessions.get(sessionId) const session = instanceSessions?.get(sessionId)
if (!session) return if (!session) {
ensureSessionStatus(instanceId, sessionId, "working", true)
return
}
applySessionStatus(instanceId, sessionId, "working", true)
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)
const role: MessageRole = info.role === "user" ? "user" : "assistant" const role: MessageRole = info.role === "user" ? "user" : "assistant"
const hasError = Boolean((info as any).error) const hasError = Boolean((info as any).error)
const status: MessageStatus = hasError ? "error" : "complete" const status: MessageStatus = hasError ? "error" : "complete"
@@ -180,8 +269,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag) const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag)
setSessionCompactionState(instanceId, info.id, isCompacting) setSessionCompactionState(instanceId, info.id, isCompacting)
const instanceSessions = sessions().get(instanceId) const instanceSessions = sessions().get(instanceId) ?? new Map<string, Session>()
if (!instanceSessions) return
const existingSession = instanceSessions.get(info.id) const existingSession = instanceSessions.get(info.id)
@@ -196,6 +284,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
providerId: "", providerId: "",
modelId: "", modelId: "",
}, },
status: isCompacting ? "compacting" : "idle",
version: info.version || "0", version: info.version || "0",
time: info.time time: info.time
? { ...info.time } ? { ...info.time }
@@ -203,7 +292,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
created: Date.now(), created: Date.now(),
updated: Date.now(), updated: Date.now(),
}, },
} as any } as Session
setSessions((prev) => { setSessions((prev) => {
const next = new Map(prev) const next = new Map(prev)
@@ -227,6 +316,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
const updatedSession = { const updatedSession = {
...existingSession, ...existingSession,
title: info.title || existingSession.title, title: info.title || existingSession.title,
status: isCompacting ? "compacting" : (existingSession.status ?? "idle"),
time: mergedTime, time: mergedTime,
revert: info.revert revert: info.revert
? { ? {
@@ -249,13 +339,23 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
} }
} }
function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void { function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID const sessionId = event.properties?.sessionID
if (!sessionId) return if (!sessionId) return
ensureSessionStatus(instanceId, sessionId, "idle")
log.info(`[SSE] Session idle: ${sessionId}`) log.info(`[SSE] Session idle: ${sessionId}`)
} }
function handleSessionStatus(instanceId: string, event: EventSessionStatus): void {
const sessionId = event.properties?.sessionID
if (!sessionId) return
const status = mapSdkSessionStatus(event.properties.status)
ensureSessionStatus(instanceId, sessionId, status, status === "working")
log.info(`[SSE] Session status updated: ${sessionId}`, { status })
}
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void { function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
const sessionID = event.properties?.sessionID const sessionID = event.properties?.sessionID
if (!sessionID) return if (!sessionID) return
@@ -263,6 +363,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
log.info(`[SSE] Session compacted: ${sessionID}`) log.info(`[SSE] Session compacted: ${sessionID}`)
setSessionCompactionState(instanceId, sessionID, false) setSessionCompactionState(instanceId, sessionID, false)
ensureSessionStatus(instanceId, sessionID, "idle")
withSession(instanceId, sessionID, (session) => { withSession(instanceId, sessionID, (session) => {
const time = { ...(session.time ?? {}) } const time = { ...(session.time ?? {}) }
@@ -368,6 +469,7 @@ export {
handleSessionCompacted, handleSessionCompacted,
handleSessionError, handleSessionError,
handleSessionIdle, handleSessionIdle,
handleSessionStatus,
handleSessionUpdate, handleSessionUpdate,
handleTuiToast, handleTuiToast,
} }

View File

@@ -1,6 +1,6 @@
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session" import type { Session, SessionStatus, Agent, Provider } from "../types/session"
import { deleteSession, loadMessages } from "./session-api" import { deleteSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { messageStoreBus } from "./message-v2/bus" import { messageStoreBus } from "./message-v2/bus"
@@ -157,6 +157,11 @@ function setSessionCompactionState(instanceId: string, sessionId: string, isComp
const time = { ...(session.time ?? {}) } const time = { ...(session.time ?? {}) }
time.compacting = isCompacting ? Date.now() : 0 time.compacting = isCompacting ? Date.now() : 0
session.time = time session.time = time
if (isCompacting) {
session.status = "compacting"
} else if (session.status === "compacting") {
session.status = "idle"
}
}) })
} }
@@ -199,6 +204,12 @@ function clearActiveParentSession(instanceId: string): void {
}) })
} }
function setSessionStatus(instanceId: string, sessionId: string, status: SessionStatus): void {
withSession(instanceId, sessionId, (session) => {
session.status = status
})
}
function getActiveParentSession(instanceId: string): Session | null { function getActiveParentSession(instanceId: string): Session | null {
const parentId = activeParentSessionId().get(instanceId) const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return null if (!parentId) return null
@@ -380,6 +391,7 @@ export {
withSession, withSession,
setSessionCompactionState, setSessionCompactionState,
setSessionPendingPermission, setSessionPendingPermission,
setSessionStatus,
setActiveSession, setActiveSession,
setActiveParentSession, setActiveParentSession,

View File

@@ -112,7 +112,7 @@ function isAssistantStillGeneratingRecord(record: MessageRecord, info?: MessageI
} }
export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus { export function deriveSessionStatusFromMessages(instanceId: string, sessionId: string): SessionStatus {
const session = getSession(instanceId, sessionId) const session = getSession(instanceId, sessionId)
if (!session) { if (!session) {
return "idle" return "idle"
@@ -162,6 +162,14 @@ export function getSessionStatus(instanceId: string, sessionId: string): Session
return "idle" return "idle"
} }
export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus {
const session = getSession(instanceId, sessionId)
if (!session) {
return "idle"
}
return session.status ?? deriveSessionStatusFromMessages(instanceId, sessionId)
}
export function isSessionBusy(instanceId: string, sessionId: string): boolean { export function isSessionBusy(instanceId: string, sessionId: string): boolean {
const status = getSessionStatus(instanceId, sessionId) const status = getSessionStatus(instanceId, sessionId)
return status === "working" || status === "compacting" return status === "working" || status === "compacting"

View File

@@ -26,6 +26,7 @@ import {
setActiveParentSession, setActiveParentSession,
setActiveSession, setActiveSession,
setSessionDraftPrompt, setSessionDraftPrompt,
setSessionStatus,
} from "./session-state" } from "./session-state"
import { getDefaultModel } from "./session-models" import { getDefaultModel } from "./session-models"
@@ -56,6 +57,7 @@ import {
handleSessionCompacted, handleSessionCompacted,
handleSessionError, handleSessionError,
handleSessionIdle, handleSessionIdle,
handleSessionStatus,
handleSessionUpdate, handleSessionUpdate,
handleTuiToast, handleTuiToast,
} from "./session-events" } from "./session-events"
@@ -68,6 +70,7 @@ sseManager.onSessionUpdate = handleSessionUpdate
sseManager.onSessionCompacted = handleSessionCompacted sseManager.onSessionCompacted = handleSessionCompacted
sseManager.onSessionError = handleSessionError sseManager.onSessionError = handleSessionError
sseManager.onSessionIdle = handleSessionIdle sseManager.onSessionIdle = handleSessionIdle
sseManager.onSessionStatus = handleSessionStatus
sseManager.onTuiToast = handleTuiToast sseManager.onTuiToast = handleTuiToast
sseManager.onPermissionUpdated = handlePermissionUpdated sseManager.onPermissionUpdated = handlePermissionUpdated
sseManager.onPermissionReplied = handlePermissionReplied sseManager.onPermissionReplied = handlePermissionReplied
@@ -109,6 +112,7 @@ export {
setActiveParentSession, setActiveParentSession,
setActiveSession, setActiveSession,
setSessionDraftPrompt, setSessionDraftPrompt,
setSessionStatus,
updateSessionAgent, updateSessionAgent,
updateSessionModel, updateSessionModel,
} }

View File

@@ -27,6 +27,7 @@ export interface Session
} }
version: string // Include version from SDK Session version: string // Include version from SDK Session
pendingPermission?: boolean // Indicates if session is waiting on user permission pendingPermission?: boolean // Indicates if session is waiting on user permission
status: SessionStatus // Single source of truth for session status
} }
// Adapter function to convert SDK Session to client Session // Adapter function to convert SDK Session to client Session
@@ -42,6 +43,7 @@ export function createClientSession(
parentId: sdkSession.parentID || null, parentId: sdkSession.parentID || null,
agent, agent,
model, model,
status: "idle",
} }
} }