diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 30cd984f7..99349c5a4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1730,17 +1730,27 @@ export class InteractiveMode { } private async rebindCurrentSession(options: { renderBeforeBind?: boolean } = {}): Promise { + const session = this.session; + this.unsubscribe?.(); this.unsubscribe = undefined; this.applyRuntimeSettings(); + if (options.renderBeforeBind) { this.renderCurrentSessionState(); this.subscribeToAgent(); - await this.bindCurrentSessionExtensions(); - } else { - await this.bindCurrentSessionExtensions(); + } + + await this.bindCurrentSessionExtensions(); + + if (this.session !== session) { + return; + } + + if (!options.renderBeforeBind) { this.subscribeToAgent(); } + await this.updateAvailableProviderCount(); this.updateEditorBorderColor(); this.updateTerminalTitle(); diff --git a/packages/coding-agent/test/suite/regressions/startup-session-rebind-duplicate-subscription.test.ts b/packages/coding-agent/test/suite/regressions/startup-session-rebind-duplicate-subscription.test.ts new file mode 100644 index 000000000..7b641d7f8 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/startup-session-rebind-duplicate-subscription.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts"; + +type RebindContext = { + session: object; + unsubscribe?: () => void; + applyRuntimeSettings: () => void; + renderCurrentSessionState: () => void; + bindCurrentSessionExtensions: () => Promise; + subscribeToAgent: () => void; + updateAvailableProviderCount: () => Promise; + updateEditorBorderColor: () => void; + updateTerminalTitle: () => void; +}; + +type InteractiveModePrototype = { + rebindCurrentSession(this: RebindContext, options?: { renderBeforeBind?: boolean }): Promise; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; + +describe("overlapping startup and replacement session rebinds", () => { + it("does not subscribe from the stale startup rebind", async () => { + const startupSession = {}; + const replacementSession = {}; + let resolveStartupBind!: () => void; + let resolveReplacementBind!: () => void; + + const startupBind = new Promise((resolve) => { + resolveStartupBind = resolve; + }); + const replacementBind = new Promise((resolve) => { + resolveReplacementBind = resolve; + }); + + const subscribeToAgent = vi.fn(); + const updateTerminalTitle = vi.fn(); + let bindCount = 0; + + const context: RebindContext = { + session: startupSession, + applyRuntimeSettings: () => {}, + renderCurrentSessionState: () => {}, + bindCurrentSessionExtensions: () => { + bindCount += 1; + return bindCount === 1 ? startupBind : replacementBind; + }, + subscribeToAgent, + updateAvailableProviderCount: async () => {}, + updateEditorBorderColor: () => {}, + updateTerminalTitle, + }; + + const startupRebind = interactiveModePrototype.rebindCurrentSession.call(context); + expect(bindCount).toBe(1); + + context.session = replacementSession; + const replacementRebind = interactiveModePrototype.rebindCurrentSession.call(context, { + renderBeforeBind: true, + }); + + expect(bindCount).toBe(2); + expect(subscribeToAgent).toHaveBeenCalledTimes(1); + + resolveStartupBind(); + await startupRebind; + + expect(subscribeToAgent).toHaveBeenCalledTimes(1); + expect(updateTerminalTitle).not.toHaveBeenCalled(); + + resolveReplacementBind(); + await replacementRebind; + + expect(subscribeToAgent).toHaveBeenCalledTimes(1); + expect(updateTerminalTitle).toHaveBeenCalledTimes(1); + }); +});