diff --git a/packages/ui/src/sync/__tests__/event-reducer.test.ts b/packages/ui/src/sync/__tests__/event-reducer.test.ts index 449eb17e..518a4e50 100644 --- a/packages/ui/src/sync/__tests__/event-reducer.test.ts +++ b/packages/ui/src/sync/__tests__/event-reducer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Event, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client" +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" @@ -8,6 +8,7 @@ function state(overrides: Partial = {}): State { ...INITIAL_STATE, message: {}, part: {}, + session_status: {}, ...overrides, } } @@ -87,6 +88,68 @@ describe("applyDirectoryEvent", () => { expect(result).toBe(true) }) + test("skips duplicate session status events", () => { + const draft = state() + const busyStatus = { type: "busy" } as SessionStatus + const event = { + type: "session.status", + properties: { sessionID: "ses_1", status: busyStatus }, + } as Event + + expect(applyDirectoryEvent(draft, event)).toBe(true) + const statusRef = draft.session_status.ses_1 + + expect(applyDirectoryEvent(draft, event)).toBe(false) + expect(draft.session_status.ses_1).toBe(statusRef) + }) + + test("skips duplicate session idle events", () => { + const draft = state() + const event = { + type: "session.idle", + properties: { sessionID: "ses_1" }, + } as Event + + expect(applyDirectoryEvent(draft, event)).toBe(true) + const statusRef = draft.session_status.ses_1 + + expect(applyDirectoryEvent(draft, event)).toBe(false) + expect(draft.session_status.ses_1).toBe(statusRef) + }) + + test("skips duplicate session error idle-state events", () => { + const draft = state() + const event = { + type: "session.error", + properties: { sessionID: "ses_1" }, + } as Event + + expect(applyDirectoryEvent(draft, event)).toBe(true) + const statusRef = draft.session_status.ses_1 + + expect(applyDirectoryEvent(draft, event)).toBe(false) + expect(draft.session_status.ses_1).toBe(statusRef) + }) + + test("detects retry status metadata changes", () => { + const draft = state({ + session_status: { + ses_1: { type: "retry", attempt: 1, message: "rate limited", next: 10 } as SessionStatus, + }, + }) + + const event = { + type: "session.status", + properties: { + sessionID: "ses_1", + status: { type: "retry", attempt: 2, message: "rate limited", next: 20 } as SessionStatus, + }, + } as Event + + expect(applyDirectoryEvent(draft, event)).toBe(true) + expect((draft.session_status.ses_1 as Extract).attempt).toBe(2) + }) + test("updates permission request arrays immutably", () => { const initialPermissions = [ { id: "perm_1", sessionID: "ses_1" } as PermissionRequest, diff --git a/packages/ui/src/sync/event-reducer.ts b/packages/ui/src/sync/event-reducer.ts index f1004d48..837f211a 100644 --- a/packages/ui/src/sync/event-reducer.ts +++ b/packages/ui/src/sync/event-reducer.ts @@ -90,6 +90,18 @@ function shouldPreserveExistingPart(previous: Part, next: Part): boolean { return false } +function areSessionStatusesEqual(left: SessionStatus | undefined, right: SessionStatus): boolean { + if (left === right) return true + if (!left || left.type !== right.type) return false + if (left.type === "retry") { + return right.type === "retry" + && left.attempt === right.attempt + && left.message === right.message + && left.next === right.next + } + return true +} + // --------------------------------------------------------------------------- // Global events // --------------------------------------------------------------------------- @@ -219,19 +231,30 @@ export function applyDirectoryEvent( case "session.status": { const props = event.properties as { sessionID: string; status: SessionStatus } + if (areSessionStatusesEqual(draft.session_status[props.sessionID], props.status)) { + return false + } draft.session_status[props.sessionID] = props.status return true } case "session.idle": { const props = event.properties as { sessionID: string } - draft.session_status[props.sessionID] = { type: "idle" } + const status = { type: "idle" } as const + if (areSessionStatusesEqual(draft.session_status[props.sessionID], status)) { + return false + } + draft.session_status[props.sessionID] = status return true } case "session.error": { const props = event.properties as { sessionID: string } - draft.session_status[props.sessionID] = { type: "idle" } + const status = { type: "idle" } as const + if (areSessionStatusesEqual(draft.session_status[props.sessionID], status)) { + return false + } + draft.session_status[props.sessionID] = status return true }