From 315abf21e6385b316cf1a296f736d8af67da912b Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Tue, 6 Jan 2026 18:03:42 +0000 Subject: [PATCH] Fix session status hydration and compaction transitions --- packages/ui/src/lib/hooks/use-commands.ts | 3 - packages/ui/src/stores/session-api.ts | 56 +++++--- packages/ui/src/stores/session-events.ts | 108 ++++++--------- packages/ui/src/stores/session-state.ts | 31 ----- packages/ui/src/stores/session-status.ts | 158 +--------------------- packages/ui/src/types/session.ts | 13 +- 6 files changed, 87 insertions(+), 282 deletions(-) diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index 96c2d2c5..8a09c481 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -11,7 +11,6 @@ import { getSessions, setActiveSession, } from "../../stores/sessions" -import { setSessionCompactionState } from "../../stores/session-compaction" import { showAlertDialog } from "../../stores/alerts" import type { Instance } from "../../types/instance" import type { MessageRecord } from "../../stores/message-v2/types" @@ -241,7 +240,6 @@ export function useCommands(options: UseCommandsOptions) { if (!session) return try { - setSessionCompactionState(instance.id, sessionId, true) await requestData( instance.client.session.summarize({ sessionID: sessionId, @@ -251,7 +249,6 @@ export function useCommands(options: UseCommandsOptions) { "session.summarize", ) } catch (error) { - setSessionCompactionState(instance.id, sessionId, false) log.error("Failed to compact session", error) const message = error instanceof Error ? error.message : "Failed to compact session" showAlertDialog(`Compact failed: ${message}`, { diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 913d6b91..35b9657c 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -1,9 +1,8 @@ -import type { Session } from "../types/session" +import { mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" import type { Message } from "../types/message" import { instances } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" -import { setSessionCompactionState } from "./session-compaction" import { activeSessionId, agents, @@ -19,7 +18,6 @@ import { setProviders, setSessionInfoByInstance, setSessions, - setSessionStatus, sessions, loading, setLoading, @@ -29,7 +27,6 @@ import { import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" -import { deriveSessionStatusFromMessages } from "./session-status" import { seedSessionMessagesV2, reconcilePendingPermissionsV2 } from "./message-v2/bridge" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" @@ -81,15 +78,31 @@ async function fetchSessions(instanceId: string): Promise { return } + let statusById: Record = {} + try { + const statusResponse = await instance.client.session.status() + if (statusResponse.data && typeof statusResponse.data === "object") { + statusById = statusResponse.data as Record + } + } catch (error) { + log.error("Failed to fetch session status:", error) + } + const existingSessions = sessions().get(instanceId) for (const apiSession of response.data) { 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 + let status: SessionStatus + if (existingStatus === "compacting") { + status = "compacting" + } else { + const rawStatus = (apiSession as any)?.status ?? statusById[apiSession.id] + const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" + status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle" + } + sessionMap.set(apiSession.id, { id: apiSession.id, instanceId, @@ -97,7 +110,7 @@ async function fetchSessions(instanceId: string): Promise { parentId: apiSession.parentID || null, agent: existingSession?.agent ?? "", model: existingSession?.model ?? { providerId: "", modelId: "" }, - status: isCompacting ? "compacting" : (existingStatus ?? "idle"), + status, version: apiSession.version, time: { ...apiSession.time, @@ -138,11 +151,6 @@ async function fetchSessions(instanceId: string): Promise { return next }) - for (const session of sessionMap.values()) { - const flag = (session.time as (Session["time"] & { compacting?: number | boolean }) | undefined)?.compacting - const active = typeof flag === "number" ? flag > 0 : Boolean(flag) - setSessionCompactionState(instanceId, session.id, active) - } pruneDraftPrompts(instanceId, new Set(sessionMap.keys())) } catch (error) { @@ -380,7 +388,6 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { + const next = new Map(prev) + const loadedSet = next.get(instanceId) || new Set() + loadedSet.add(sessionId) + next.set(instanceId, loadedSet) + return next + }) + + if (apiMessages.length === 0) { return } @@ -630,11 +650,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false // After message hydration, try to attach any pending permissions to tool-call part ids. reconcilePendingPermissionsV2(instanceId, sessionId) - if (!alreadyLoaded) { - const nextStatus = deriveSessionStatusFromMessages(instanceId, sessionId) - setSessionStatus(instanceId, sessionId, nextStatus) - } - + } catch (error) { log.error("Failed to load messages:", error) throw error diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index bfb49e3c..2f62e0bf 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -21,13 +21,12 @@ import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from " import { showToastNotification, ToastVariant } from "../lib/notifications" import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances" import { showAlertDialog } from "./alerts" -import { createClientSession, Session, SessionStatus } from "../types/session" +import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" import { loadMessages } from "./session-api" -import { setSessionCompactionState } from "./session-compaction" import { applyPartUpdateV2, replaceMessageIdV2, @@ -56,31 +55,16 @@ interface TuiToastEvent { const ALLOWED_TOAST_VARIANTS = new Set(["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, - bumpUpdatedOnTransition = false, -) { +function applySessionStatus(instanceId: string, sessionId: string, status: SessionStatus) { withSession(instanceId, sessionId, (session) => { const current = session.status ?? "idle" if (current === status) return false - session.status = status - - if (bumpUpdatedOnTransition) { - session.time = { ...(session.time ?? {}), updated: Date.now() } + if (current === "compacting" && status !== "compacting") { + return false } + + session.status = status }) } @@ -94,7 +78,17 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise< "session.get", ) - const fetched = createClientSession(info, instanceId) + let fetchedStatus: SessionStatus = "idle" + try { + const statuses = await requestData>(instance.client.session.status(), "session.status") + const rawStatus = (info as any)?.status ?? statuses?.[sessionId] + const hasType = rawStatus && typeof rawStatus === "object" && typeof rawStatus.type === "string" + fetchedStatus = hasType ? mapSdkSessionStatus(rawStatus) : "idle" + } catch (error) { + log.error("Failed to fetch session status", error) + } + + const fetched = createClientSession(info, instanceId, "", { providerId: "", modelId: "" }, fetchedStatus) let updatedInstanceSessions: Map | undefined @@ -106,7 +100,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise< ...fetched, agent: existing?.agent ?? fetched.agent, model: existing?.model ?? fetched.model, - status: existing?.status ?? fetched.status, + status: existing?.status === "compacting" ? "compacting" : fetched.status, pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission, } instanceSessions.set(sessionId, merged) @@ -124,19 +118,14 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise< } } -function ensureSessionStatus( - instanceId: string, - sessionId: string, - status: SessionStatus, - bumpUpdatedOnTransition = false, -) { +function ensureSessionStatus(instanceId: string, sessionId: string, status: SessionStatus) { const instanceSessions = sessions().get(instanceId) const existing = instanceSessions?.get(sessionId) if (existing) { if ((existing.status ?? "idle") === status) { return } - applySessionStatus(instanceId, sessionId, status, bumpUpdatedOnTransition) + applySessionStatus(instanceId, sessionId, status) return } @@ -148,7 +137,7 @@ function ensureSessionStatus( const pending = (async () => { const fetched = await fetchSessionInfo(instanceId, sessionId) if (!fetched) return - applySessionStatus(instanceId, sessionId, status, bumpUpdatedOnTransition) + applySessionStatus(instanceId, sessionId, status) })() pendingSessionFetches.set(key, pending) @@ -193,17 +182,10 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId if (!sessionId || !messageId) return - const session = instanceSessions?.get(sessionId) - if (!session) { - ensureSessionStatus(instanceId, sessionId, "working", true) - return + if (part.type === "compaction") { + ensureSessionStatus(instanceId, sessionId, "compacting") } - if (session.status !== "working" && session.status !== "compacting") { - applySessionStatus(instanceId, sessionId, "working", true) - } - - const store = messageStoreBus.getOrCreate(instanceId) const role: MessageRole = resolveMessageRole(messageInfo) const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now() @@ -246,15 +228,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const messageId = typeof info.id === "string" ? info.id : undefined if (!sessionId || !messageId) return - const session = instanceSessions?.get(sessionId) - if (!session) { - ensureSessionStatus(instanceId, sessionId, "working", true) - return - } - - if (session.status !== "working" && session.status !== "compacting") { - applySessionStatus(instanceId, sessionId, "working", true) - } + withSession(instanceId, sessionId, (session) => { + session.time = { ...(session.time ?? {}), updated: Date.now() } + }) const store = messageStoreBus.getOrCreate(instanceId) @@ -295,10 +271,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo if (!info) return - const compactingFlag = info.time?.compacting - const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag) - setSessionCompactionState(instanceId, info.id, isCompacting) - const instanceSessions = sessions().get(instanceId) ?? new Map() const existingSession = instanceSessions.get(info.id) @@ -314,7 +286,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo providerId: "", modelId: "", }, - status: isCompacting ? "compacting" : "idle", + status: "idle", version: info.version || "0", time: info.time ? { ...info.time } @@ -344,14 +316,10 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo ...existingSession.time, ...(info.time ?? {}), } - if (!info.time?.updated) { - mergedTime.updated = Date.now() - } - const updatedSession = { ...existingSession, title: info.title || existingSession.title, - status: isCompacting ? "compacting" : (existingSession.status ?? "idle"), + status: existingSession.status ?? "idle", time: mergedTime, revert: info.revert ? { @@ -383,7 +351,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void { const sessionId = event.properties?.sessionID if (!sessionId) return - ensureSessionStatus(instanceId, sessionId, "idle", true) + ensureSessionStatus(instanceId, sessionId, "idle") log.info(`[SSE] Session idle: ${sessionId}`) } @@ -392,7 +360,7 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi if (!sessionId) return const status = mapSdkSessionStatus(event.properties.status) - ensureSessionStatus(instanceId, sessionId, status, true) + ensureSessionStatus(instanceId, sessionId, status) log.info(`[SSE] Session status updated: ${sessionId}`, { status }) } @@ -402,14 +370,14 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted log.info(`[SSE] Session compacted: ${sessionID}`) - setSessionCompactionState(instanceId, sessionID, false) - ensureSessionStatus(instanceId, sessionID, "idle", true) - - withSession(instanceId, sessionID, (session) => { - const time = { ...(session.time ?? {}) } - time.compacting = 0 - session.time = time - }) + const existing = sessions().get(instanceId)?.get(sessionID) + if (existing) { + withSession(instanceId, sessionID, (session) => { + session.status = "working" + }) + } else { + ensureSessionStatus(instanceId, sessionID, "working") + } loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error)) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index be89a6f2..84b5aaf4 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -290,36 +290,6 @@ function withSession(instanceId: string, sessionId: string, updater: (session: S } } -function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void { - withSession(instanceId, sessionId, (session) => { - const time = { ...(session.time ?? {}) } as Session["time"] & { compacting?: number | boolean; updated?: number } - const compactingFlag = time.compacting - const wasCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag) - - const shouldAlreadyBeCompacting = isCompacting - const isAlreadyCorrect = - wasCompacting === isCompacting && - (shouldAlreadyBeCompacting ? session.status === "compacting" : session.status !== "compacting") - - if (isAlreadyCorrect) { - return false - } - - if (wasCompacting !== isCompacting) { - time.compacting = isCompacting ? Date.now() : 0 - time.updated = Date.now() - } - - session.time = time - - if (isCompacting) { - session.status = "compacting" - } else if (session.status === "compacting") { - session.status = "idle" - } - }) -} - function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void { withSession(instanceId, sessionId, (session) => { if (session.pendingPermission === pending) return false @@ -547,7 +517,6 @@ export { clearInstanceDraftPrompts, pruneDraftPrompts, withSession, - setSessionCompactionState, setSessionPendingPermission, setSessionStatus, setActiveSession, diff --git a/packages/ui/src/stores/session-status.ts b/packages/ui/src/stores/session-status.ts index f5b30121..02d33a62 100644 --- a/packages/ui/src/stores/session-status.ts +++ b/packages/ui/src/stores/session-status.ts @@ -1,173 +1,17 @@ import type { Session, SessionStatus } from "../types/session" -import type { MessageInfo } from "../types/message" -import type { MessageRecord } from "./message-v2/types" import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state" -import { isSessionCompactionActive } from "./session-compaction" -import { messageStoreBus } from "./message-v2/bus" function getSession(instanceId: string, sessionId: string): Session | null { const instanceSessions = sessions().get(instanceId) return instanceSessions?.get(sessionId) ?? null } -function isSessionCompacting(session: Session): boolean { - const time = (session.time as (Session["time"] & { compacting?: number }) | undefined) - const compactingFlag = time?.compacting - if (typeof compactingFlag === "number") { - return compactingFlag > 0 - } - return Boolean(compactingFlag) -} - -function getLatestInfoFromStore(instanceId: string, sessionId: string, role?: MessageInfo["role"]): MessageInfo | undefined { - const store = messageStoreBus.getOrCreate(instanceId) - const messageIds = store.getSessionMessageIds(sessionId) - let latest: MessageInfo | undefined - let latestTimestamp = Number.NEGATIVE_INFINITY - for (const id of messageIds) { - const info = store.getMessageInfo(id) - if (!info) continue - if (role && info.role !== role) continue - const timestamp = info.time?.created ?? 0 - if (timestamp >= latestTimestamp) { - latest = info - latestTimestamp = timestamp - } - } - return latest -} - -function getLastMessageFromStore(instanceId: string, sessionId: string): MessageRecord | undefined { - const store = messageStoreBus.getOrCreate(instanceId) - const messageIds = store.getSessionMessageIds(sessionId) - let latest: MessageRecord | undefined - let latestTimestamp = Number.NEGATIVE_INFINITY - for (const id of messageIds) { - const record = store.getMessage(id) - if (!record) continue - const info = store.getMessageInfo(id) - const timestamp = info?.time?.created ?? record.createdAt ?? Number.NEGATIVE_INFINITY - if (timestamp >= latestTimestamp) { - latest = record - latestTimestamp = timestamp - } - } - return latest -} - - -function getInfoCreatedTimestamp(info?: MessageInfo): number { - if (!info) { - return Number.NEGATIVE_INFINITY - } - const created = info.time?.created - if (typeof created === "number" && Number.isFinite(created)) { - return created - } - return Number.NEGATIVE_INFINITY -} - -function getAssistantCompletionTimestamp(info?: MessageInfo): number { - if (!info) { - return Number.NEGATIVE_INFINITY - } - const completed = (info.time as { completed?: number } | undefined)?.completed - if (typeof completed === "number" && Number.isFinite(completed)) { - return completed - } - return Number.NEGATIVE_INFINITY -} - -function isAssistantInfoPending(info?: MessageInfo): boolean { - if (!info) { - return false - } - const completed = (info.time as { completed?: number } | undefined)?.completed - if (completed === undefined || completed === null) { - return true - } - const created = getInfoCreatedTimestamp(info) - return completed < created -} - -function isAssistantStillGeneratingRecord(record: MessageRecord, info?: MessageInfo): boolean { - if (record.role !== "assistant") { - return false - } - - if (record.status === "error") { - return false - } - - if (record.status === "streaming" || record.status === "sending") { - return true - } - - const completedAt = (info?.time as { completed?: number } | undefined)?.completed - if (completedAt !== undefined && completedAt !== null) { - return false - } - - return !(record.status === "complete" || record.status === "sent") -} - - -export function deriveSessionStatusFromMessages(instanceId: string, sessionId: string): SessionStatus { - const session = getSession(instanceId, sessionId) - if (!session) { - return "idle" - } - - const store = messageStoreBus.getOrCreate(instanceId) - - if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) { - return "compacting" - } - - const latestUserInfo = getLatestInfoFromStore(instanceId, sessionId, "user") - const latestAssistantInfo = getLatestInfoFromStore(instanceId, sessionId, "assistant") - - const lastRecord = getLastMessageFromStore(instanceId, sessionId) - - if (!lastRecord) { - const latestInfo = latestUserInfo ?? latestAssistantInfo - if (!latestInfo) { - return "idle" - } - if (latestInfo.role === "user") { - return "working" - } - const infoCompleted = latestInfo.time?.completed - return infoCompleted ? "idle" : "working" - } - - if (lastRecord.role === "user") { - return "working" - } - const infoForRecord = store.getMessageInfo(lastRecord.id) ?? latestAssistantInfo - if (infoForRecord && isAssistantStillGeneratingRecord(lastRecord, infoForRecord)) { - return "working" - } - - if (isAssistantInfoPending(latestAssistantInfo)) { - return "working" - } - - const userTimestamp = getInfoCreatedTimestamp(latestUserInfo) - const assistantCompletedAt = getAssistantCompletionTimestamp(latestAssistantInfo) - if (userTimestamp > assistantCompletedAt) { - return "working" - } - - 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) + return session.status ?? "idle" } export type InstanceSessionIndicatorStatus = "permission" | SessionStatus diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index f01aac44..102e2285 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -4,6 +4,7 @@ import type { Provider as SDKProvider, Model as SDKModel, } from "@opencode-ai/sdk" +import type { SessionStatus as SDKSessionStatus } from "@opencode-ai/sdk/v2/client" // Export SDK types for external use export type { @@ -15,6 +16,15 @@ export type { export type SessionStatus = "idle" | "working" | "compacting" +export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus { + if (!status || status.type === "idle") { + return "idle" + } + + // "busy" and "retry" both mean there's active work. + return "working" +} + // Our client-specific Session interface extending SDK Session export interface Session extends Omit { @@ -36,6 +46,7 @@ export function createClientSession( instanceId: string, agent: string = "", model: { providerId: string; modelId: string } = { providerId: "", modelId: "" }, + status: SessionStatus = "idle", ): Session { return { ...sdkSession, @@ -43,7 +54,7 @@ export function createClientSession( parentId: sdkSession.parentID || null, agent, model, - status: "idle", + status, } }