Merge PR #8911: fix(session): generate titles from /skill invocation args (@qiyi71w)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user