Merge PR #8911: fix(session): generate titles from /skill invocation args (@qiyi71w)

This commit is contained in:
can1357
2026-08-19 01:38:34 +02:00
9 changed files with 321 additions and 6 deletions
+3
View File
@@ -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:<name>` invocations, so titles now see the skill name and args instead of only later assistant text.
## [17.3.7] - 2026-08-17
@@ -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;
@@ -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();
}
@@ -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.
*
@@ -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,32 @@
/** 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;
}
@@ -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<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;
});
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<void>();
const secondStarted = Promise.withResolvers<void>();
const firstResponse = Promise.withResolvers<ai.AssistantMessage>();
const secondResponse = Promise.withResolvers<ai.AssistantMessage>();
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("<title>old skill</title>"));
expect(await generateTitle.mock.results[0]?.value).toBe("old skill");
await Promise.resolve();
expect(setSessionName).not.toHaveBeenCalled();
expect(session.sessionName).toBeUndefined();
secondResponse.resolve(createAssistantMessage("<title>replacement session</title>"));
expect(await generateTitle.mock.results[1]?.value).toBe("replacement session");
await setSessionName.mock.results[0]?.value;
expect(session.sessionName).toBe("replacement session");
});
});
@@ -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<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 = { 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<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 { 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");
});
});