fix(session): title user /skill invocations from name and args
Session titles ignored /skill:<name> <args> because the invocation is a custom skill-prompt, not a user turn. Feed the chip or reconstructed /skill line into first-title generation and replan context, never the expanded SKILL.md body.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed session-title generation ignoring user `/skill:<name>` invocations, so titles now see the skill name and args instead of only later assistant text.
|
||||
|
||||
## [17.3.7] - 2026-08-17
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Compact title-model input for a user-invoked `/skill:<name>` 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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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<SkillPromptDetails> = {
|
||||
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<SkillPromptDetails> = {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user