From c87ee802b10e3dd5efb2ac2e884723cc9cdf47d4 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 21 Jul 2026 20:43:56 +0000 Subject: [PATCH] fix(session): skipped titles for local extension commands - Classified registered extension commands before automatic title generation while preserving normal and prompt-producing slash command titles. - Added regression coverage for one-shot local handling, unnamed-session preservation, and forwarded prompt eligibility. Fixes #6061 --- packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/controllers/input-controller.ts | 15 ++- .../src/prompts/system/workflow-notice.md | 6 +- .../input-controller-orphan-submit.test.ts | 96 +++++++++++++++++++ 4 files changed, 115 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f32dc885..457cfe9d1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed locally consumed extension commands triggering automatic title generation and exposing their command text to the title model ([#6061](https://github.com/can1357/oh-my-pi/issues/6061)). + ## [17.0.5] - 2026-07-18 ### Added diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 2ba96cfbc..badf66aaa 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -822,12 +822,25 @@ export class InputController { // First, move any pending bash components to chat this.ctx.flushPendingBashComponents(); + // AgentSession.prompt() consumes registered extension commands locally. + // Classify them here because title generation starts before prompt dispatch. + const extensionCommandSpace = text.indexOf(" "); + const isLocalExtensionCommand = + text.startsWith("/") && + runner?.getCommand(extensionCommandSpace === -1 ? text.slice(1) : text.slice(1, extensionCommandSpace)) !== + undefined; + // Auto-generate a session title while the session is still unnamed. // Greetings / acknowledgements / empty input carry no task, so they are // skipped deterministically (no model invoked, no download-progress UI) // and the session stays unnamed — the next user message gets a fresh // chance, so titling defers past "hi" instead of latching onto it. - if (!this.ctx.sessionManager.getSessionName() && !$env.PI_NO_TITLE && !isLowSignalTitleInput(text)) { + if ( + !isLocalExtensionCommand && + !this.ctx.sessionManager.getSessionName() && + !$env.PI_NO_TITLE && + !isLowSignalTitleInput(text) + ) { this.#showTinyTitleDownloadProgress(this.ctx.settings.get("providers.tinyModel")); this.ctx.session .generateTitle(text) diff --git a/packages/coding-agent/src/prompts/system/workflow-notice.md b/packages/coding-agent/src/prompts/system/workflow-notice.md index d15ad9127..09a849bef 100644 --- a/packages/coding-agent/src/prompts/system/workflow-notice.md +++ b/packages/coding-agent/src/prompts/system/workflow-notice.md @@ -47,7 +47,7 @@ For independent per-item chains (review → verify, fetch → extract → score) schema: FINDINGS_SCHEMA, }); return await parallel(found.findings.map((f) => async () => ({ - ...f, + …f, verdict: await agent( `Refute if you can (default refuted when unsure): ${f.title}`, { label: `verify:${f.file}`, schema: VERDICT_SCHEMA }, @@ -57,8 +57,6 @@ For independent per-item chains (review → verify, fetch → extract → score) phase("Review"); const results = await parallel(DIMENSIONS.map((d) => async () => reviewAndVerify(d))); const confirmed = results.flat().filter((f) => f.verdict.is_real); - - Reach for `pipeline()` only when a stage genuinely needs ALL of the previous stage first — dedup/merge across the whole set, early-exit on zero, or "compare against the other findings" — because its inter-stage barrier makes every item wait for the slowest peer: **Python (`eval`, Python backend):** @@ -80,8 +78,6 @@ Reach for `pipeline()` only when a stage genuinely needs ALL of the previous sta const verdicts = await parallel(findings.map((f) => async () => await agent(verifyPrompt(f), { schema: VERDICT_SCHEMA }), )); - - Use ordinary code between calls to flatten/map/filter; don't add a barrier just for that. Nested `parallel()` pools each cap independently, so keep total fan-out sane. diff --git a/packages/coding-agent/test/input-controller-orphan-submit.test.ts b/packages/coding-agent/test/input-controller-orphan-submit.test.ts index c71c52fe8..733543632 100644 --- a/packages/coding-agent/test/input-controller-orphan-submit.test.ts +++ b/packages/coding-agent/test/input-controller-orphan-submit.test.ts @@ -5,11 +5,14 @@ import type { ImageContent } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { ExtensionRuntime, loadExtensionFromFactory } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; +import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; 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 { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; import { TempDir } from "@oh-my-pi/pi-utils"; /** @@ -235,4 +238,97 @@ describe("InputController orphaned submit", () => { expect(ctx.editor.pendingImages).toEqual([image]); expect(ctx.editor.pendingImageLinks).toEqual([undefined]); }); + it("skips automatic titles only for locally consumed extension commands", async () => { + const previousNoTitle = Bun.env.PI_NO_TITLE; + delete Bun.env.PI_NO_TITLE; + const tempDir = TempDir.createSync("@pi-extension-title-"); + let session: AgentSession | undefined; + let authStorage: AuthStorage | undefined; + try { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected built-in anthropic model to exist"); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + const modelRegistry = new ModelRegistry(authStorage); + const sessionManager = SessionManager.inMemory(tempDir.path()); + const settings = Settings.isolated({ + "compaction.enabled": false, + "providers.tinyModel": "online", + }); + const localHandler = vi.fn(async () => {}); + const runtime = new ExtensionRuntime(); + const extension = await loadExtensionFromFactory( + pi => { + pi.registerCommand("widget-status", { + description: "Display local widget status", + handler: localHandler, + }); + }, + tempDir.path(), + new EventBus(), + runtime, + "widget-status-test", + ); + const extensionRunner = new ExtensionRunner( + [extension], + runtime, + tempDir.path(), + sessionManager, + modelRegistry, + undefined, + settings, + ); + const agent = new Agent({ + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }); + session = new AgentSession({ + agent, + sessionManager, + settings, + modelRegistry, + extensionRunner, + }); + const titleSpy = vi.spyOn(session, "generateTitle").mockResolvedValue(null); + const { ctx, editor } = createContext(session); + ctx.sessionManager = sessionManager; + ctx.settings = settings; + const controller = new InputController(ctx); + controller.setupEditorSubmitHandler(); + + await editor.onSubmit?.("/widget-status"); + + expect(localHandler).toHaveBeenCalledTimes(1); + expect(titleSpy).not.toHaveBeenCalled(); + expect(sessionManager.getSessionName()).toBeUndefined(); + expect(session.messages).toEqual([]); + + const promptSpy = vi.spyOn(session, "prompt").mockResolvedValue(true); + for (const forwardedText of ["inspect the widgets", "/custom-prompt inspect the widgets"]) { + titleSpy.mockClear(); + promptSpy.mockClear(); + + await editor.onSubmit?.(forwardedText); + + expect(promptSpy).toHaveBeenCalledWith(forwardedText, { + streamingBehavior: "steer", + images: undefined, + }); + expect(titleSpy).toHaveBeenCalledWith(forwardedText); + } + } finally { + vi.restoreAllMocks(); + await session?.dispose(); + authStorage?.close(); + tempDir.removeSync(); + if (previousNoTitle === undefined) { + delete Bun.env.PI_NO_TITLE; + } else { + Bun.env.PI_NO_TITLE = previousNoTitle; + } + } + }); });