diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5597d222b..fb7d0df5e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `defaultThinkingLevel: auto` skipping classification for user-invoked `/skill:` 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 diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index cd5679440..519515699 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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:` 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; diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 031c25b24..976393aad 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -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:` 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"; } diff --git a/packages/coding-agent/test/agent-session-role-thinking.test.ts b/packages/coding-agent/test/agent-session-role-thinking.test.ts index 5b6cedd11..506215b8c 100644 --- a/packages/coding-agent/test/agent-session-role-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-role-thinking.test.ts @@ -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: 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({