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:
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user