diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 34a2889a0..b801e13b5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -23,6 +23,9 @@ ### Fixed - Fixed subagents aborting when external thinking exposes `think` as the required prelude before their remaining tools become callable ([#8909](https://github.com/can1357/oh-my-pi/pull/8909) by [@olegpulatov](https://github.com/olegpulatov)). +### Fixed + +- Fixed session-title generation ignoring user `/skill:` invocations, so titles now see the skill name and args instead of only later assistant text. ## [17.3.7] - 2026-08-17 diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index d0d260fda..50a633bc2 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -173,6 +173,11 @@ export class InputController { }, ) {} + /** Session-level title starts (user `/skill:` via promptCustomMessage) reuse this UI. */ + notifyTitleGenerationStart(): void { + this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel")); + } + #enhancedPaste?: EnhancedPasteController; #focusedLeftTapListenerInstalled = false; #focusedPasteListenerInstalled = false; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index e268045e4..84f918821 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -903,6 +903,9 @@ export class InteractiveMode implements InteractiveModeContext { this.#selectorController = new SelectorController(this); this.#focusController = new SessionFocusController(this); this.#inputController = new InputController(this); + this.session.setTitleGenerationStart?.(() => { + this.#inputController.notifyTitleGenerationStart(); + }); this.#observerRegistry = new SessionObserverRegistry(); } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 5cc68b613..b93105728 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -338,6 +338,7 @@ import { SessionProviderBoundary, type SessionProviderBoundaryHost } from "./ses import { SessionStatsTracker, type SessionStatsTrackerHost } from "./session-stats"; import { SessionTools, type SessionToolsHost } from "./session-tools"; import type { ShakeMode, ShakeResult } from "./shake-types"; +import { skillPromptTitleInput } from "./skill-title-input"; import { ToolChoiceQueue } from "./tool-choice-queue"; import { planTurnPersistence, sameMessageContent, sessionMessagePersistenceKey } from "./turn-persistence"; import { TurnRecovery, type TurnRecoveryHost } from "./turn-recovery"; @@ -533,6 +534,8 @@ export class AgentSession { * generation path. Refresh via {@link AgentSession.setTitleSystemPrompt} when * the session cwd changes. */ #titleSystemPrompt: string | undefined; + #titleGenerationStart: (() => void) | undefined; + #titleGenerationInFlightFor: string | undefined; #titleGenerationAbortController = new AbortController(); #toolChoiceQueue = new ToolChoiceQueue(); @@ -5471,11 +5474,20 @@ export class AgentSession { let keywordNotices: CustomMessage[] = []; if (message.customType === SKILL_PROMPT_MESSAGE_TYPE && message.attribution === "user") { const details = message.details; + let skillName: string | undefined; let skillArgs = ""; - if (details && typeof details === "object" && "args" in details && typeof details.args === "string") { - skillArgs = details.args; + if (details && typeof details === "object") { + if ("name" in details && typeof details.name === "string") skillName = details.name; + if ("args" in details && typeof details.args === "string") skillArgs = details.args; } keywordNotices = this.#createMagicKeywordNotices(skillArgs); + this.maybeStartTitleGeneration( + skillPromptTitleInput({ + name: skillName, + args: skillArgs, + queueChipText: options?.queueChipText, + }), + ); } if (options?.queueOnly) { @@ -6543,14 +6555,31 @@ 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)) { + const sessionId = this.sessionManager.getSessionId(); + if ( + isLocalExtensionCommand || + this.sessionName || + this.#titleGenerationInFlightFor === sessionId || + $env.PI_NO_TITLE || + isLowSignalTitleInput(firstMessage) + ) { return; } - onStart?.(); + this.#titleGenerationInFlightFor = sessionId; + try { + (onStart ?? this.#titleGenerationStart)?.(); + } catch (error) { + 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"); } @@ -6561,6 +6590,11 @@ export class AgentSession { reason: "uncaught-auto-title-error", error: err instanceof Error ? err.message : String(err), }); + }) + .finally(() => { + if (this.#titleGenerationInFlightFor === sessionId) { + this.#titleGenerationInFlightFor = undefined; + } }); } @@ -6607,6 +6641,12 @@ export class AgentSession { this.#titleSystemPrompt = prompt; } + /** Install the interactive title-download UI hook. Used when `/skill:` starts + * titling from {@link promptCustomMessage} without the input-controller callback. */ + setTitleGenerationStart(handler: (() => void) | undefined): void { + this.#titleGenerationStart = handler; + } + /** * Abort current operation and wait for agent to become idle. * diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 4926ad5f9..ae12b9659 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -38,6 +38,7 @@ export { import type { OutputMeta } from "../tools/output-meta"; import { formatOutputNotice } from "../tools/output-meta"; +import { titleTextFromSkillPrompt } from "./skill-title-input"; export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt"; export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic"; @@ -163,6 +164,11 @@ function thinkingFromContent(content: unknown): string { } function titleConversationTurnFromMessage(message: AgentMessage): TitleConversationTurn | undefined { + if (message.role === "custom") { + const text = titleTextFromSkillPrompt(message); + if (!text) return undefined; + return { role: "user", text }; + } if (message.role !== "user" && message.role !== "assistant") return undefined; const text = textFromContent(message.content); const thinking = message.role === "assistant" ? thinkingFromContent(message.content) : undefined; diff --git a/packages/coding-agent/src/session/skill-title-input.ts b/packages/coding-agent/src/session/skill-title-input.ts new file mode 100644 index 000000000..1ed06f24c --- /dev/null +++ b/packages/coding-agent/src/session/skill-title-input.ts @@ -0,0 +1,32 @@ +/** Compact title-model input for a user-invoked `/skill:` prompt. */ +export function skillPromptTitleInput(input: { name?: string; args?: string; queueChipText?: string }): string { + const chip = input.queueChipText?.trim(); + if (chip) return chip; + const name = input.name?.trim(); + const args = input.args?.trim(); + if (name && args) return `/skill:${name} ${args}`; + if (name) return `/skill:${name}`; + return args ?? ""; +} + +/** Title text for a persisted skill-prompt custom message. Never the expanded SKILL.md body. */ +export function titleTextFromSkillPrompt(message: { + role: string; + customType?: string; + attribution?: string; + details?: unknown; +}): string | undefined { + if (message.role !== "custom" || message.customType !== "skill-prompt" || message.attribution !== "user") { + return undefined; + } + let name: string | undefined; + let args: string | undefined; + let queueChipText: string | undefined; + if (message.details && typeof message.details === "object") { + const details = message.details as Record; + if (typeof details.name === "string") name = details.name; + if (typeof details.args === "string") args = details.args; + if (typeof details.__queueChipText === "string") queueChipText = details.__queueChipText; + } + return skillPromptTitleInput({ name, args, queueChipText }) || 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 4bb52d2e5..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 @@ -68,4 +68,103 @@ 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(); + const response = Promise.withResolvers(); + 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("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"); + }); }); diff --git a/packages/coding-agent/test/session/messages.test.ts b/packages/coding-agent/test/session/messages.test.ts index b0a4e1b17..5fe179174 100644 --- a/packages/coding-agent/test/session/messages.test.ts +++ b/packages/coding-agent/test/session/messages.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { + buildReplanTitleContext, type CustomMessage, convertToLlm, INTERRUPTED_THINKING_MESSAGE_TYPE, @@ -281,3 +282,58 @@ describe("replaceLlmImagesWithText", () => { expect(replaceLlmImagesWithText(converted, "[image omitted]")).toBe(converted); }); }); + +describe("buildReplanTitleContext", () => { + it("titles a user skill invocation from skill args, not the expanded skill body", () => { + const skill: CustomMessage = { + role: "custom", + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: + '[IMPORTANT: User invoked the "implement" skill]\n\nImplement the work described by the user.\n\nUse this skill.', + display: true, + details: { + name: "implement", + path: "/tmp/implement/SKILL.md", + args: "issues/07-manual-llm.md 创建临时工作树实现", + lineCount: 20, + }, + attribution: "user", + timestamp: 1, + }; + const context = buildReplanTitleContext([skill, settledAssistant("先读 implement 技能、ticket 07")]); + + expect(context).toContain("07-manual-llm.md"); + expect(context).toContain("ticket 07"); + expect(context).not.toContain("Use this skill."); + expect(context).not.toContain("IMPORTANT"); + }); + + it("does not feed an autoloaded skill prompt into title context", () => { + const skill = customMessage(SKILL_PROMPT_MESSAGE_TYPE, "agent"); + skill.details = { name: "atomic-commit", path: "/tmp/SKILL.md", lineCount: 1, args: "issues/07-manual-llm.md" }; + + expect(buildReplanTitleContext([skill])).toBe(""); + }); + + it("prefers the operator /skill chip over persisted name and args", () => { + const skill: CustomMessage = { + role: "custom", + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: "Use this skill.", + display: true, + details: { + name: "implement", + path: "/tmp/implement/SKILL.md", + args: "issues/07-manual-llm.md", + lineCount: 1, + __queueChipText: "/skill:implement issues/08-app-settings.md", + }, + attribution: "user", + timestamp: 1, + }; + const context = buildReplanTitleContext([skill]); + + expect(context).toContain("08-app-settings.md"); + expect(context).not.toContain("07-manual-llm.md"); + }); +}); diff --git a/packages/coding-agent/test/session/skill-title-input.test.ts b/packages/coding-agent/test/session/skill-title-input.test.ts new file mode 100644 index 000000000..20950e07a --- /dev/null +++ b/packages/coding-agent/test/session/skill-title-input.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { skillPromptTitleInput, titleTextFromSkillPrompt } from "../../src/session/skill-title-input"; +import { formatTitleConversationContext } from "../../src/tiny/message-preproc"; + +describe("skillPromptTitleInput", () => { + it("prefers the operator chip over reconstructed skill args", () => { + const text = skillPromptTitleInput({ + name: "implement", + args: "issues/07-manual-llm.md", + queueChipText: "/skill:implement issues/08-app-settings.md", + }); + expect(text.includes("08-app-settings.md")).toBe(true); + expect(text.includes("07-manual-llm.md")).toBe(false); + }); + + it("reconstructs /skill:name args when the chip was stripped", () => { + expect(skillPromptTitleInput({ name: "implement", args: "issues/07-manual-llm.md 创建临时工作树实现" })).toBe( + "/skill:implement issues/07-manual-llm.md 创建临时工作树实现", + ); + }); +}); + +describe("titleTextFromSkillPrompt", () => { + it("reads args from a user skill-prompt and ignores expanded body fields", () => { + const text = titleTextFromSkillPrompt({ + role: "custom", + customType: "skill-prompt", + attribution: "user", + details: { + name: "implement", + path: "/tmp/implement/SKILL.md", + args: "issues/07-manual-llm.md", + lineCount: 20, + }, + }); + expect(text).toBe("/skill:implement issues/07-manual-llm.md"); + }); + + it("ignores autoloaded skill prompts", () => { + expect( + titleTextFromSkillPrompt({ + role: "custom", + customType: "skill-prompt", + attribution: "agent", + details: { name: "implement", args: "issues/07-manual-llm.md" }, + }), + ).toBeUndefined(); + }); +}); + +describe("replan title envelope", () => { + it("puts skill args in the user turn and keeps assistant text", () => { + const text = titleTextFromSkillPrompt({ + role: "custom", + customType: "skill-prompt", + attribution: "user", + details: { + name: "implement", + args: "issues/07-manual-llm.md 创建临时工作树实现", + }, + }); + const context = formatTitleConversationContext([ + { role: "user", text }, + { role: "assistant", text: "先读 implement 技能、ticket 07" }, + ]); + + expect(context).toContain("07-manual-llm.md"); + expect(context).toContain("ticket 07"); + expect(context).not.toContain("SKILL.md"); + }); +});