fix(coding-agent): classify user-invoked /skill turns under auto thinking

A user-invoked /skill:<name> reaches the session as a user-attributed skill custom message (role custom, attribution user) whose expanded SKILL.md body is the task prompt. The auto-thinking gate in #promptWithMessage only accepted role === "user", so these turns skipped classifyDifficulty/applyAutoThinkingLevel and the effort stayed stuck on pending auto.

Broaden the gate to also accept user-invoked skill prompts via the now-exported isUserInvokedSkillPrompt helper; agent-originated and autoload skill injections stay excluded.

Fixes #8554
This commit is contained in:
roboomp
2026-08-14 13:26:16 +00:00
parent ffd53ff92a
commit 8afd71d921
4 changed files with 73 additions and 5 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `defaultThinkingLevel: auto` skipping classification for user-invoked `/skill:<name>` turns, which left the effort stuck on pending `auto`; user-attributed skill prompts now classify like any user turn while agent/autoload injections stay excluded ([#8554](https://github.com/can1357/oh-my-pi/issues/8554)).
## [17.3.4] - 2026-08-14
### Changed
@@ -297,6 +297,7 @@ import {
type InterruptedThinkingDetails,
isEmptyErrorTurn,
isUserInterruptAbort,
isUserInvokedSkillPrompt,
logProviderTurnError,
normalizeCustomMessagePayload,
type PythonExecutionMessage,
@@ -5568,10 +5569,14 @@ export class AgentSession {
}
// Auto thinking: classify this real user turn and set the effective level
// before the model request. Synthetic/tool-continuation turns (developer/
// custom roles) and non-auto sessions are skipped. Never blocks the turn —
// failures fall back to a concrete level inside the helper.
if (this.isAutoThinking && message.role === "user") {
// before the model request. A user-invoked `/skill:<name>` arrives as a
// user-attributed skill custom message whose expanded body is the task
// prompt, so it counts as a user turn. Synthetic/tool-continuation turns
// (developer roles), agent-originated or autoloaded skill injections, and
// non-auto sessions are skipped. Never blocks the turn — failures fall
// back to a concrete level inside the helper.
const isUserTurn = message.role === "user" || (message.role === "custom" && isUserInvokedSkillPrompt(message));
if (this.isAutoThinking && isUserTurn) {
await this.#models.applyAutoThinkingLevel(expandedText, generation);
if (this.#promptGeneration !== generation) {
return;
@@ -1085,7 +1085,8 @@ function customMessageContentToLlmContent(content: CustomMessage["content"]): (T
return typeof content === "string" ? [{ type: "text", text: content }] : content;
}
function isUserInvokedSkillPrompt(message: CustomMessage): boolean {
/** True for a `/skill:<name>` prompt the user invoked directly (attribution `user`), as opposed to an agent/autoload injection. */
export function isUserInvokedSkillPrompt(message: CustomMessage): boolean {
return message.customType === SKILL_PROMPT_MESSAGE_TYPE && message.attribution === "user";
}
@@ -8,6 +8,7 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SKILL_PROMPT_MESSAGE_TYPE } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import {
AUTO_THINKING,
@@ -374,6 +375,63 @@ describe("AgentSession role model thinking behavior", () => {
expect(session.agent.state.thinkingLevel).toBe(Effort.Medium);
});
it("classifies a user-invoked /skill turn under auto (resolves concrete effort)", async () => {
const model = getAnthropicModelOrThrow("claude-sonnet-4-5");
await createSession({
initialModelId: model.id,
initialThinkingLevel: Effort.High,
modelRoles: { default: `${model.provider}/${model.id}` },
});
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.Medium);
session.setThinkingLevel(AUTO_THINKING);
expect(session.autoResolvedThinkingLevel()).toBeUndefined();
// A /skill:<name> invocation reaches the session as a user-attributed
// custom message, not a `user` role. It is still a real user turn.
await session.promptCustomMessage({
customType: SKILL_PROMPT_MESSAGE_TYPE,
content: "Expanded SKILL.md body: implement the focused parser fix",
display: true,
details: { name: "implement", path: "/skills/implement/SKILL.md", args: "the parser" },
attribution: "user",
});
expect(classifierSpy).toHaveBeenCalledTimes(1);
expect(classifierSpy.mock.calls[0]?.[0]).toContain("implement the focused parser fix");
expect(promptSpy).toHaveBeenCalledTimes(1);
expect(session.configuredThinkingLevel()).toBe(AUTO_THINKING);
expect(session.thinkingLevel).toBe(Effort.Medium);
expect(session.autoResolvedThinkingLevel()).toBe(Effort.Medium);
expect(session.agent.state.thinkingLevel).toBe(Effort.Medium);
});
it("does not classify an agent-originated skill custom message under auto", async () => {
const model = getAnthropicModelOrThrow("claude-sonnet-4-5");
await createSession({
initialModelId: model.id,
initialThinkingLevel: Effort.High,
modelRoles: { default: `${model.provider}/${model.id}` },
});
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.Medium);
session.setThinkingLevel(AUTO_THINKING);
// Autoloaded / agent-originated skill injections must stay excluded.
await session.promptCustomMessage({
customType: SKILL_PROMPT_MESSAGE_TYPE,
content: "Autoloaded skill body",
display: false,
details: { name: "autoload", path: "/skills/autoload/SKILL.md" },
attribution: "agent",
});
expect(classifierSpy).not.toHaveBeenCalled();
expect(session.autoResolvedThinkingLevel()).toBeUndefined();
});
it("keeps auto active on resume (pending until the next turn reclassifies)", async () => {
const model = getAnthropicModelOrThrow("claude-sonnet-4-5");
const agent = new Agent({