diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2f7b500ff..43c25fc8d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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 ### Changed diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index cb490b418..c2f2d64f6 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -308,6 +308,7 @@ import { sanitizeAssistantForReparentedHistory, USER_INTERRUPT_LABEL, } from "./messages"; +import { skillPromptTitleInput } from "./skill-title-input"; import { ModelControls, type ModelControlsHost } from "./model-controls"; import { isPrewalkPlanNudge, PrewalkCoordinator, type PrewalkCoordinatorHost } from "./prewalk"; import { @@ -5466,11 +5467,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) { 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..7567cee74 --- /dev/null +++ b/packages/coding-agent/src/session/skill-title-input.ts @@ -0,0 +1,36 @@ +/** 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/session/messages.test.ts b/packages/coding-agent/test/session/messages.test.ts index b0a4e1b17..2225886fe 100644 --- a/packages/coding-agent/test/session/messages.test.ts +++ b/packages/coding-agent/test/session/messages.test.ts @@ -3,6 +3,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { type CustomMessage, + buildReplanTitleContext, convertToLlm, INTERRUPTED_THINKING_MESSAGE_TYPE, replaceLlmImagesWithText, @@ -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 = { ...skill.details, 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..7331a259a --- /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 { formatTitleConversationContext } from "../../src/tiny/message-preproc"; +import { skillPromptTitleInput, titleTextFromSkillPrompt } from "../../src/session/skill-title-input"; + +describe("skillPromptTitleInput", () => { + it("uses the operator /skill chip when present", () => { + expect( + skillPromptTitleInput({ + name: "implement", + args: "issues/07-manual-llm.md", + queueChipText: "/skill:implement issues/08-app-settings.md", + }), + ).toBe("/skill:implement issues/08-app-settings.md"); + }); + + 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"); + }); +});