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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -106,4 +106,65 @@ describe("AgentSession title generation disposal", () => {
|
||||
response.resolve(createAssistantMessage("<title>manual llm</title>"));
|
||||
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<void>();
|
||||
const secondStarted = Promise.withResolvers<void>();
|
||||
const firstResponse = Promise.withResolvers<ai.AssistantMessage>();
|
||||
const secondResponse = Promise.withResolvers<ai.AssistantMessage>();
|
||||
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("<title>old skill</title>"));
|
||||
expect(await generateTitle.mock.results[0]?.value).toBe("old skill");
|
||||
await Promise.resolve();
|
||||
expect(setSessionName).not.toHaveBeenCalled();
|
||||
expect(session.sessionName).toBeUndefined();
|
||||
|
||||
secondResponse.resolve(createAssistantMessage("<title>replacement session</title>"));
|
||||
expect(await generateTitle.mock.results[1]?.value).toBe("replacement session");
|
||||
await setSessionName.mock.results[0]?.value;
|
||||
expect(session.sessionName).toBe("replacement session");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user