From dd23b3e054c0bb7d1a28c7551bfdba18aba6dd55 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 16 Jun 2026 23:40:47 +0200 Subject: [PATCH] fix(sessions): dedupe started session when created broadcast races The session.created global event (added with spawn_session) could arrive in the initiating tab before the start request's HTTP response resolved. applyCreatedSession inserted a plain session row (archive/reload actions) and then startSession unconditionally prepended the cached version (delete action), leaving two badges with the same id. Make the optimistic insert idempotent by filtering out any existing entry with the same id before prepending the cached session, so the locally cached session always wins regardless of event ordering. --- .../fix-duplicate-started-session-badge.md | 5 ++++ .../src/controllers/sessionController.test.ts | 30 +++++++++++++++++++ .../src/controllers/sessionController.ts | 5 +++- 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-duplicate-started-session-badge.md diff --git a/.changeset/fix-duplicate-started-session-badge.md b/.changeset/fix-duplicate-started-session-badge.md new file mode 100644 index 0000000..92bd7e5 --- /dev/null +++ b/.changeset/fix-duplicate-started-session-badge.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Fix a duplicate session appearing in the list when starting a new session. The `session.created` broadcast (added with the spawn_session tool) could race ahead of the start request's HTTP response in the same tab, leaving two badges with the same id — one with archive/reload actions and one with delete. The optimistic insert now replaces any entry the broadcast added, so the locally cached session (with its delete action and draft support) always wins. diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 8e6dd49..6a1cc3e 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -174,6 +174,36 @@ describe("SessionController", () => { expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]); }); + it("does not duplicate a started session when its session.created broadcast races the HTTP response", async () => { + const storage = new MemoryStorage(); + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); + const started: SessionInfo = { ...oldSession, id: "started-session", path: "/tmp/started-session.jsonl" }; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [] }; + const socket = new FakeSocket(); + const api: typeof defaultApi = { + ...defaultApi, + startSession: () => { + // Simulate the broadcast arriving before the HTTP response resolves. + controller.applyGlobalEvent({ type: "session.created", session: started }); + return Promise.resolve(started); + }, + messages: () => Promise.resolve(emptyPage), + status: (session) => Promise.resolve(status(sessionLookupId(session))), + }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { api, socket }, + ); + + await controller.startSession(); + + expect(state.sessions.map((session) => session.id)).toEqual(["started-session"]); + expect(isCachedNewSessionInfo(state.sessions[0])).toBe(true); + }); + it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => { let resolvePrompt: (() => void) | undefined; let promptArgs: { attachments?: PromptAttachment[] } | undefined; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 144fc23..e4e7710 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -94,7 +94,10 @@ export class SessionController { const session = await this.api.startSession(workspace.path, machineId); rememberCachedNewSession(session, machineId); const cachedSession = markCachedNewSessionInfo(session, machineId); - this.setState({ sessions: [cachedSession, ...this.getState().sessions] }); + // Drop any entry the session.created broadcast may have inserted for this + // same session before the HTTP response resolved, so the cached marker + // (and its delete action) wins instead of leaving a duplicate badge. + this.setState({ sessions: [cachedSession, ...this.getState().sessions.filter((candidate) => candidate.id !== cachedSession.id)] }); await this.selectSession(cachedSession); } catch (error) { this.setState({ error: String(error) });