From d5745aaac9118e8f998b1d85b16e7611f85a6e40 Mon Sep 17 00:00:00 2001 From: Leonid <127580858+bashrusakh@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:19:24 +1100 Subject: [PATCH] fix(sync): keep session renames stable (#2043) * fix(sync): keep session renames stable * fix(sync): clarify rename mirror flow * fix(sync): clarify archive comment --------- Co-authored-by: bashrusakh Co-authored-by: Bohdan Triapitsyn --- .../src/sync/__tests__/event-reducer.test.ts | 23 ++++++++ .../__tests__/session-event-freshness.test.ts | 33 ++++++++++++ .../sync-context-session-events.test.ts | 46 ++++++++++++++++ packages/ui/src/sync/event-reducer.ts | 11 ++++ packages/ui/src/sync/session-actions.test.ts | 41 +++++++++++++++ packages/ui/src/sync/session-actions.ts | 14 ++++- .../ui/src/sync/session-event-freshness.ts | 15 ++++++ packages/ui/src/sync/session-event-router.ts | 52 +++++++++++++++++++ packages/ui/src/sync/sync-context.tsx | 42 +-------------- 9 files changed, 234 insertions(+), 43 deletions(-) create mode 100644 packages/ui/src/sync/__tests__/session-event-freshness.test.ts create mode 100644 packages/ui/src/sync/__tests__/sync-context-session-events.test.ts create mode 100644 packages/ui/src/sync/session-event-freshness.ts create mode 100644 packages/ui/src/sync/session-event-router.ts diff --git a/packages/ui/src/sync/__tests__/event-reducer.test.ts b/packages/ui/src/sync/__tests__/event-reducer.test.ts index bfeb8438..e196f83a 100644 --- a/packages/ui/src/sync/__tests__/event-reducer.test.ts +++ b/packages/ui/src/sync/__tests__/event-reducer.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import type { Session } from "@opencode-ai/sdk/v2" import type { Event, Part, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/sdk/v2/client" import { applyDirectoryEvent } from "../event-reducer" import { INITIAL_STATE, type State } from "../types" @@ -55,6 +56,14 @@ function topLevelSessionOnlyPartUpdatedEvent(): Event { } as Event } +function buildSession(title: string, time: Session["time"]): Session { + return { + id: "ses_1", + title, + time, + } as Session +} + describe("applyDirectoryEvent", () => { test("returns typed materialization when delta arrives before parts", () => { const result = applyDirectoryEvent(state(), deltaEvent()) @@ -129,6 +138,20 @@ describe("applyDirectoryEvent", () => { }) }) + test("skips stale session.updated events so a newer title survives", () => { + const draft = state({ session: [buildSession("New Title", { created: 1, updated: 20 })] }) + + const result = applyDirectoryEvent(draft, { + type: "session.updated", + properties: { + info: buildSession("Old Title", { created: 1, updated: 10 }), + }, + } as Event) + + expect(result).toBe(false) + expect(draft.session[0]?.title).toBe("New Title") + }) + test("applies part update without materialization when owning message exists", () => { const draft = state({ message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "assistant", time: { created: 1 } } as never] }, diff --git a/packages/ui/src/sync/__tests__/session-event-freshness.test.ts b/packages/ui/src/sync/__tests__/session-event-freshness.test.ts new file mode 100644 index 00000000..8295341d --- /dev/null +++ b/packages/ui/src/sync/__tests__/session-event-freshness.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import type { Session } from "@opencode-ai/sdk/v2" + +import { shouldSkipStaleSessionEvent } from "../session-event-freshness" + +const buildSession = (title: string, time: Partial>): Session => ({ + id: "ses_1", + title, + time: time as Session["time"], +} as Session) + +describe("shouldSkipStaleSessionEvent", () => { + test("skips a stale SSE session update after a newer local rename", () => { + const current = buildSession("New Title", { created: 1, updated: 20 }) + const incoming = buildSession("Old Title", { created: 1, updated: 10 }) + + expect(shouldSkipStaleSessionEvent(current, incoming)).toBe(true) + }) + + test("allows a fresher SSE update to apply", () => { + const current = buildSession("Old Title", { created: 1, updated: 10 }) + const incoming = buildSession("New Title", { created: 1, updated: 20 }) + + expect(shouldSkipStaleSessionEvent(current, incoming)).toBe(false) + }) + + test("falls back to created timestamp when updated is missing", () => { + const current = buildSession("Current", { created: 20 }) + const incoming = buildSession("Incoming", { created: 10 }) + + expect(shouldSkipStaleSessionEvent(current, incoming)).toBe(true) + }) +}) diff --git a/packages/ui/src/sync/__tests__/sync-context-session-events.test.ts b/packages/ui/src/sync/__tests__/sync-context-session-events.test.ts new file mode 100644 index 00000000..bdae78f6 --- /dev/null +++ b/packages/ui/src/sync/__tests__/sync-context-session-events.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import type { Event, Session } from "@opencode-ai/sdk/v2/client" + +let currentSessions: Session[] = [] +const upsertedSessions: Session[] = [] + +mock.module("@/stores/useGlobalSessionsStore", () => ({ + useGlobalSessionsStore: { + getState: () => ({ + activeSessions: currentSessions, + archivedSessions: [] as Session[], + upsertSession: (session: Session) => { + upsertedSessions.push(session) + }, + }), + }, +})) +import { applySessionEventToGlobalSessions } from "../session-event-router" + +const buildSession = (title: string, time: Session["time"]): Session => ({ + id: "ses_1", + title, + time, +} as Session) + +const buildEvent = (session: Session): Event => ({ + type: "session.updated", + properties: { + info: session, + }, +} as Event) + +describe("applySessionEventToGlobalSessions", () => { + beforeEach(() => { + currentSessions = [] + upsertedSessions.length = 0 + }) + + test("skips stale global session.updated echoes after a newer rename", () => { + currentSessions = [buildSession("New Title", { created: 1, updated: 20 })] + + applySessionEventToGlobalSessions(buildEvent(buildSession("Old Title", { created: 1, updated: 10 }))) + + expect(upsertedSessions).toEqual([]) + }) +}) diff --git a/packages/ui/src/sync/event-reducer.ts b/packages/ui/src/sync/event-reducer.ts index b9120b03..5288b140 100644 --- a/packages/ui/src/sync/event-reducer.ts +++ b/packages/ui/src/sync/event-reducer.ts @@ -14,6 +14,7 @@ import type { FileDiff, GlobalState, State } from "./types" import { dropSessionCaches } from "./session-cache" import { stripSessionDiffSnapshots } from "./sanitize" import { syncDebug } from "./debug" +import { shouldSkipStaleSessionEvent } from "./session-event-freshness" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const DELTA_OVERLAP_FIELDS = ["text", "output"] as const @@ -226,6 +227,9 @@ export function applyDirectoryEvent( const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info) const sessions = draft.session const result = Binary.search(sessions, info.id, (s) => s.id) + if (result.found && shouldSkipStaleSessionEvent(sessions[result.index], info)) { + return false + } if (result.found) { sessions[result.index] = info } else { @@ -240,6 +244,13 @@ export function applyDirectoryEvent( const info = stripSessionDiffSnapshots((event.properties as { info: Session }).info) const sessions = draft.session const result = Binary.search(sessions, info.id, (s) => s.id) + // Keep the freshness check ahead of the archive branch: direct archive + // responses handle the store update on their own (optimistic removal + + // SDK response), so stale SSE echoes should not win just because they + // mark the session archived. + if (result.found && shouldSkipStaleSessionEvent(sessions[result.index], info)) { + return false + } if (info.time.archived) { if (result.found) sessions.splice(result.index, 1) diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 42daf31e..ae4df0a3 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -10,6 +10,7 @@ let sessionRevertResult: { data?: unknown; error?: unknown; response?: { status? let questionReplyError: unknown | null = null let questionRejectError: unknown | null = null let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {} +let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {} let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] } const globalUpsertedSessions: unknown[] = [] @@ -52,6 +53,14 @@ const mockSdk = { replyCalls.push({ method: "session.abort", params }) return Promise.resolve({ data: true }) }), + updateSession: mock((sessionId: string, changes: Record, directory?: string | null) => { + replyCalls.push({ method: "session.update", params: { sessionID: sessionId, ...changes, directory } }) + return Promise.resolve(sessionUpdateResult.data as Session) + }), + update: mock((params: Record) => { + replyCalls.push({ method: "session.update", params }) + return Promise.resolve(sessionUpdateResult) + }), share: mock((params: Record) => { replyCalls.push({ method: "session.share", params }) return Promise.resolve(sessionShareResult) @@ -112,6 +121,10 @@ mock.module("@/lib/opencode/client", () => ({ } return Promise.resolve(sessionRevertResult.data) }), + updateSession: mock((sessionId: string, changes: Record, directory?: string | null) => { + replyCalls.push({ method: "session.update", params: { sessionID: sessionId, ...changes, directory } }) + return Promise.resolve(sessionUpdateResult.data) + }), }, })) @@ -340,6 +353,34 @@ describe("shareSession live state", () => { }) }) +describe("updateSessionTitle live state", () => { + beforeEach(() => { + replyCalls.length = 0 + globalUpsertedSessions.length = 0 + sessionUpdateResult = {} + }) + + test("updates the live directory store after renaming", async () => { + const oldSession = { id: "session-a", title: "Old Title", time: { created: 1, updated: 1 } } as Session + const updatedSession = { id: "session-a", title: "New Title", time: { created: 1, updated: 2 } } as Session + const sessionStore = createStore({}, { session: [oldSession] }) + const childStores = createChildStores([["/test/project", sessionStore]]) + sessionUpdateResult = { data: updatedSession } + + const { setActionRefs, updateSessionTitle } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/current/project") + + await updateSessionTitle("session-a", "New Title") + + const updateCall = replyCalls.find((call) => call.method === "session.update") + expect(updateCall?.params.sessionID).toBe("session-a") + expect(updateCall?.params.title).toBe("New Title") + expect(updateCall?.params.directory).toBe("/test/project") + expect(globalUpsertedSessions).toEqual([updatedSession]) + expect(sessionStore.getState().session[0].title).toBe("New Title") + }) +}) + describe("optimisticSend target directory", () => { beforeEach(() => { replyCalls.length = 0 diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index d6af6b60..dee72102 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -161,9 +161,9 @@ export function getSessionLastAssistantModel(sessionId: string): { providerID: s } } -function updateLiveSession(session: Session, directory?: string): void { +function updateLiveSession(session: Session, directory?: string): boolean { const stores = _childStores - if (!stores) return + if (!stores) return false const candidates = directory ? [[directory, stores.getChild(directory)] as const] @@ -178,8 +178,17 @@ function updateLiveSession(session: Session, directory?: string): void { const next = [...current] next[index] = mergeSessionDirectoryMetadata(session, current[index]) store.setState({ session: next }) + return true + } + + return false +} + +export function mirrorSessionIntoLiveStores(session: Session, directory?: string): void { + if (directory && updateLiveSession(session, directory)) { return } + updateLiveSession(session) } function dir() { @@ -624,6 +633,7 @@ export async function updateSessionTitle(sessionId: string, title: string): Prom const sessionDirectory = getSessionDirectory(sessionId) const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory) useGlobalSessionsStore.getState().upsertSession(session) + mirrorSessionIntoLiveStores(session, sessionDirectory) } export async function shareSession(sessionId: string): Promise { diff --git a/packages/ui/src/sync/session-event-freshness.ts b/packages/ui/src/sync/session-event-freshness.ts new file mode 100644 index 00000000..f5f73d7e --- /dev/null +++ b/packages/ui/src/sync/session-event-freshness.ts @@ -0,0 +1,15 @@ +import type { Session } from "@opencode-ai/sdk/v2" + +const getSessionRecencyTimestamp = (session: Session): number => { + const updatedAt = session.time?.updated + if (typeof updatedAt === "number" && Number.isFinite(updatedAt)) { + return updatedAt + } + const createdAt = session.time?.created + return typeof createdAt === "number" && Number.isFinite(createdAt) ? createdAt : 0 +} + +export const shouldSkipStaleSessionEvent = (currentSession: Session | null, incomingSession: Session): boolean => { + if (!currentSession) return false + return getSessionRecencyTimestamp(incomingSession) < getSessionRecencyTimestamp(currentSession) +} diff --git a/packages/ui/src/sync/session-event-router.ts b/packages/ui/src/sync/session-event-router.ts new file mode 100644 index 00000000..d9eb3adb --- /dev/null +++ b/packages/ui/src/sync/session-event-router.ts @@ -0,0 +1,52 @@ +import type { Event, Session } from "@opencode-ai/sdk/v2/client" +import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" +import { stripSessionDiffSnapshots } from "./sanitize" +import { shouldSkipStaleSessionEvent } from "./session-event-freshness" + +const getSessionInfoFromPayload = (event: Event): Session | null => { + if (event.type !== "session.created" && event.type !== "session.updated" && event.type !== "session.deleted") { + return null + } + + const properties = (event as { properties?: unknown }).properties + if (!properties || typeof properties !== "object") { + return null + } + + const info = (properties as { info?: unknown }).info + if (!info || typeof info !== "object") { + return null + } + + const session = info as Partial + if (typeof session.id !== "string" || !session.time) { + return null + } + + return stripSessionDiffSnapshots(session as Session) +} + +const getGlobalSessionSnapshot = (sessionId: string): Session | null => { + const global = useGlobalSessionsStore.getState() + return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null +} + +export const applySessionEventToGlobalSessions = (payload: Event): void => { + if (payload.type === "session.created" || payload.type === "session.updated") { + const session = getSessionInfoFromPayload(payload) + if (session) { + const currentSession = getGlobalSessionSnapshot(session.id) + if (!shouldSkipStaleSessionEvent(currentSession, session)) { + useGlobalSessionsStore.getState().upsertSession(session) + } + } + return + } + + if (payload.type === "session.deleted") { + const sessionID = (payload as { properties?: { sessionID?: string } }).properties?.sessionID ?? getSessionInfoFromPayload(payload)?.id + if (sessionID) { + useGlobalSessionsStore.getState().removeSessions([sessionID]) + } + } +} diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index cab84073..2207038a 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -25,6 +25,7 @@ import { updateStreamingState } from "./streaming" import { setActionRefs } from "./session-actions" import { setSyncRefs, getAllSyncSessions } from "./sync-refs" import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize" +import { applySessionEventToGlobalSessions } from "./session-event-router" import { syncDebug } from "./debug" import { getReconnectCandidateSessionIds } from "./reconnect-recovery" import { opencodeClient } from "@/lib/opencode/client" @@ -46,7 +47,6 @@ import { getRuntimeKey } from "@/lib/runtime-switch" import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" import { setSessionPrefetch } from "./session-prefetch-cache" import { listGlobalSessionPages } from "@/stores/globalSessions" -import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests" import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history" import { runtimeFetch } from "@/lib/runtime-fetch" @@ -659,46 +659,6 @@ const getSessionIdFromPayload = (event: Event): string | null => { return null } -const getSessionInfoFromPayload = (event: Event): Session | null => { - if (event.type !== "session.created" && event.type !== "session.updated" && event.type !== "session.deleted") { - return null - } - - const properties = (event as { properties?: unknown }).properties - if (!properties || typeof properties !== "object") { - return null - } - - const info = (properties as { info?: unknown }).info - if (!info || typeof info !== "object") { - return null - } - - const session = info as Partial - if (typeof session.id !== "string" || !session.time) { - return null - } - - return stripSessionDiffSnapshots(session as Session) -} - -const applySessionEventToGlobalSessions = (payload: Event) => { - if (payload.type === "session.created" || payload.type === "session.updated") { - const session = getSessionInfoFromPayload(payload) - if (session) { - useGlobalSessionsStore.getState().upsertSession(session) - } - return - } - - if (payload.type === "session.deleted") { - const sessionID = getSessionIdFromPayload(payload) ?? getSessionInfoFromPayload(payload)?.id - if (sessionID) { - useGlobalSessionsStore.getState().removeSessions([sessionID]) - } - } -} - const getMessageIdFromPayload = (event: Event): string | null => { const properties = (event as { properties?: unknown }).properties if (!properties || typeof properties !== "object") {