fix(session): skip queued /skill title starts while one is in flight

maybeStartTitleGeneration used to fire again for every untitled skill
prompt, so a queued /skill: during the first title request could race
and rename the session. Latch until the first request settles.
This commit is contained in:
qiyi71w
2026-08-19 02:23:14 +08:00
parent 9dc6eeae93
commit 1b122fa289
2 changed files with 56 additions and 2 deletions
@@ -535,6 +535,7 @@ export class AgentSession {
* the session cwd changes. */
#titleSystemPrompt: string | undefined;
#titleGenerationStart: (() => void) | undefined;
#titleGenerationInFlight = false;
#titleGenerationAbortController = new AbortController();
#toolChoiceQueue = new ToolChoiceQueue();
@@ -6549,10 +6550,22 @@ export class AgentSession {
this.#extensionRunner?.getCommand(
extensionCommandSpace === -1 ? firstMessage.slice(1) : firstMessage.slice(1, extensionCommandSpace),
) !== undefined;
if (isLocalExtensionCommand || this.sessionName || $env.PI_NO_TITLE || isLowSignalTitleInput(firstMessage)) {
if (
isLocalExtensionCommand ||
this.sessionName ||
this.#titleGenerationInFlight ||
$env.PI_NO_TITLE ||
isLowSignalTitleInput(firstMessage)
) {
return;
}
(onStart ?? this.#titleGenerationStart)?.();
this.#titleGenerationInFlight = true;
try {
(onStart ?? this.#titleGenerationStart)?.();
} catch (error) {
this.#titleGenerationInFlight = false;
throw error;
}
this.generateTitle(firstMessage)
.then(async title => {
// Re-check after generation so concurrent attempts cannot replace
@@ -6567,6 +6580,9 @@ export class AgentSession {
reason: "uncaught-auto-title-error",
error: err instanceof Error ? err.message : String(err),
});
})
.finally(() => {
this.#titleGenerationInFlight = false;
});
}
@@ -68,4 +68,42 @@ describe("AgentSession title generation disposal", () => {
expect(requestSignal?.aborted).toBe(true);
expect(await generation).toBeNull();
});
it("does not start a second auto-title request while the first is still in flight", 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 started = Promise.withResolvers<void>();
const response = Promise.withResolvers<ai.AssistantMessage>();
const completeSimple = vi.spyOn(ai, "completeSimple").mockImplementation(() => {
started.resolve();
return response.promise;
});
session.maybeStartTitleGeneration("/skill:implement issues/07-manual-llm.md");
await started.promise;
session.maybeStartTitleGeneration("/skill:implement issues/08-app-settings.md");
expect(completeSimple).toHaveBeenCalledTimes(1);
response.resolve(createAssistantMessage("<title>manual llm</title>"));
await response.promise;
});
});