From 8c55d224c5c62288b75bd9108b975f61b371e9e3 Mon Sep 17 00:00:00 2001 From: qiyi71w <1411348064@qq.com> Date: Wed, 19 Aug 2026 02:32:27 +0800 Subject: [PATCH] fix(session): scope in-flight title latch to session id A boolean latch survived /new and unnamed session switches, so the replacement session skipped titling and could inherit the previous skill's title. Bind the latch and the apply check to the originating session id. --- .../coding-agent/src/session/agent-session.ts | 21 ++++--- ...t-session-title-generation-dispose.test.ts | 61 +++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 788d3f41b..1883c8e2e 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -535,7 +535,7 @@ export class AgentSession { * the session cwd changes. */ #titleSystemPrompt: string | undefined; #titleGenerationStart: (() => void) | undefined; - #titleGenerationInFlight = false; + #titleGenerationInFlightFor: string | undefined; #titleGenerationAbortController = new AbortController(); #toolChoiceQueue = new ToolChoiceQueue(); @@ -6550,26 +6550,31 @@ export class AgentSession { this.#extensionRunner?.getCommand( extensionCommandSpace === -1 ? firstMessage.slice(1) : firstMessage.slice(1, extensionCommandSpace), ) !== undefined; + const sessionId = this.sessionManager.getSessionId(); if ( isLocalExtensionCommand || this.sessionName || - this.#titleGenerationInFlight || + this.#titleGenerationInFlightFor === sessionId || $env.PI_NO_TITLE || isLowSignalTitleInput(firstMessage) ) { return; } - this.#titleGenerationInFlight = true; + this.#titleGenerationInFlightFor = sessionId; try { (onStart ?? this.#titleGenerationStart)?.(); } catch (error) { - this.#titleGenerationInFlight = false; + if (this.#titleGenerationInFlightFor === sessionId) { + this.#titleGenerationInFlightFor = undefined; + } throw error; } this.generateTitle(firstMessage) .then(async title => { - // Re-check after generation so concurrent attempts cannot replace - // the first title that completed. + // Re-check after generation so a later completion cannot replace + // the first title, and a request from a replaced session cannot + // name the current one. + if (this.sessionManager.getSessionId() !== sessionId) return; if (title && !this.sessionName) { await this.sessionManager.setSessionName(title, "auto"); } @@ -6582,7 +6587,9 @@ export class AgentSession { }); }) .finally(() => { - this.#titleGenerationInFlight = false; + if (this.#titleGenerationInFlightFor === sessionId) { + this.#titleGenerationInFlightFor = undefined; + } }); } diff --git a/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts b/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts index eb9b87275..f425c62fc 100644 --- a/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts +++ b/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts @@ -106,4 +106,65 @@ describe("AgentSession title generation disposal", () => { response.resolve(createAssistantMessage("manual llm")); await response.promise; }); + + it("lets a replacement session title itself and ignores the previous request", async () => { + authStorage = await AuthStorage.create(":memory:"); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "providers.tinyModel": "online", + }); + settings.overrideModelRoles({ smol: `${model.provider}/${model.id}` }); + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: createMockModel({ responses: [{ content: ["Done"] }] }).stream, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry: new ModelRegistry(authStorage), + }); + const firstStarted = Promise.withResolvers(); + const secondStarted = Promise.withResolvers(); + const firstResponse = Promise.withResolvers(); + const secondResponse = Promise.withResolvers(); + let titleCalls = 0; + const completeSimple = vi.spyOn(ai, "completeSimple").mockImplementation(() => { + titleCalls += 1; + if (titleCalls === 1) { + firstStarted.resolve(); + return firstResponse.promise; + } + secondStarted.resolve(); + return secondResponse.promise; + }); + const generateTitle = vi.spyOn(session, "generateTitle"); + const setSessionName = vi.spyOn(session.sessionManager, "setSessionName"); + const firstSessionId = session.sessionManager.getSessionId(); + + session.maybeStartTitleGeneration("/skill:implement issues/07-manual-llm.md"); + await firstStarted.promise; + expect(await session.newSession()).toBe(true); + expect(session.sessionManager.getSessionId()).not.toBe(firstSessionId); + + session.maybeStartTitleGeneration("name the replacement session"); + await secondStarted.promise; + expect(completeSimple).toHaveBeenCalledTimes(2); + + firstResponse.resolve(createAssistantMessage("old skill")); + expect(await generateTitle.mock.results[0]?.value).toBe("old skill"); + await Promise.resolve(); + expect(setSessionName).not.toHaveBeenCalled(); + expect(session.sessionName).toBeUndefined(); + + secondResponse.resolve(createAssistantMessage("replacement session")); + expect(await generateTitle.mock.results[1]?.value).toBe("replacement session"); + await setSessionName.mock.results[0]?.value; + expect(session.sessionName).toBe("replacement session"); + }); });