diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 14fd10616..877a59a57 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -36,6 +36,7 @@ - Reduced concurrent subagent update CPU by reconstructing recent output only at progress emission boundaries. ([#5936](https://github.com/can1357/oh-my-pi/issues/5936)) - Fixed `docs/advisor-watchdog.md` overstating advisor delivery for a normal yield: the severity table listed `concern` as unconditionally interrupting and the prose promised a self-ended run could always be steered/resumed. Documented the #4840 terminal-answer exception (`concern` becomes a passive card while `blocker` normally steers, #5628) plus the plan-mode and deferred-ACP constraints that preserve would-be steers until the user resumes ([#5913](https://github.com/can1357/oh-my-pi/issues/5913)). +- Fixed subagent (task) sessions triggering an unnecessary tiny-model session-title generation call on `todo init`. Subagent sessions in a non-interactive host (print/RPC/ACP/eval/SDK/CI) have no operator-visible title and now skip the replan title refresh; interactive hosts keep it, since a live subagent focused from the Agent Hub renders its session name in the status line ([#5910](https://github.com/can1357/oh-my-pi/issues/5910)). ## [17.0.3] - 2026-07-17 diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 6a7616eb8..7c0a066a8 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -17,6 +17,7 @@ import { logger, normalizePathForComparison, postmortem, + setInteractiveHost, setProjectDir, VERSION, } from "@oh-my-pi/pi-utils"; @@ -1162,6 +1163,10 @@ export async function runRootCommand( const pipedInput = isProtocolMode ? undefined : await logger.time("readPipedInput", readPipedInput); const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined; const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined; + // Only the interactive host renders a focusable Agent Hub / subagent session + // tree; declare it so headless subagent optimizations (e.g. skipping replan + // title refresh) can tell a focusable process from a print/RPC/eval one. + setInteractiveHost(isInteractive); // Initialize discovery system with settings for provider persistence logger.time("initializeWithSettings", initializeWithSettings, settingsInstance); diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 2641d3633..ad75bced8 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -138,6 +138,7 @@ import { getInstallId, isBunTestRuntime, isEnoent, + isInteractiveHost, logger, postmortem, prompt, @@ -9696,6 +9697,13 @@ export class AgentSession { } #scheduleReplanTitleRefresh(): void { + // Headless subagent sessions have no operator-visible title, so a todo-init + // replan refresh only burns a tiny-model call whose result lands in JSONL + // and is never shown (issue #5910). In an interactive host the operator can + // focus a live subagent from the Agent Hub, where the status line renders + // its session name — so keep the refresh there and only skip subagents when + // no focusable UI exists (print/RPC/ACP/eval/SDK/CI). + if (this.#agentKind === "sub" && !isInteractiveHost()) return; if (this.#replanTitleRefreshInFlight) return; if (!this.settings.get("title.refreshOnReplan")) return; if (this.sessionManager.titleSource === "user") return; diff --git a/packages/coding-agent/test/agent-session-eager-todo.test.ts b/packages/coding-agent/test/agent-session-eager-todo.test.ts index 5e1c42f7b..8a4276fa7 100644 --- a/packages/coding-agent/test/agent-session-eager-todo.test.ts +++ b/packages/coding-agent/test/agent-session-eager-todo.test.ts @@ -7,13 +7,13 @@ import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream" 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 { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AgentSession, type AgentSessionConfig } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TodoTool } from "@oh-my-pi/pi-coding-agent/tools"; -import { TempDir } from "@oh-my-pi/pi-utils"; +import { setInteractiveHost, TempDir } from "@oh-my-pi/pi-utils"; import { type } from "arktype"; import eagerTodoPrompt from "../src/prompts/system/eager-todo.md" with { type: "text" }; import { createAssistantMessage } from "./helpers/agent-session-setup"; @@ -103,7 +103,10 @@ describe("AgentSession eager todo enforcement", () => { let authStorage: AuthStorage | undefined; const observedCalls: ObservedPromptCall[] = []; - async function createSession(settingsOverride: Record = {}): Promise { + async function createSession( + settingsOverride: Record = {}, + sessionOverride: Partial = {}, + ): Promise { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); @@ -183,17 +186,21 @@ describe("AgentSession eager todo enforcement", () => { settings, modelRegistry, toolRegistry, + ...sessionOverride, }); } - async function recreateSession(settingsOverride: Record = {}): Promise { + async function recreateSession( + settingsOverride: Record = {}, + sessionOverride: Partial = {}, + ): Promise { await session.dispose(); authStorage?.close(); authStorage = undefined; streamCallCount = 0; scriptedResponses = []; observedCalls.length = 0; - await createSession(settingsOverride); + await createSession(settingsOverride, sessionOverride); } function waitForSessionName(expected: string): Promise { @@ -376,6 +383,72 @@ describe("AgentSession eager todo enforcement", () => { expect(session.sessionManager.getSessionName()).toBe("Manual parser title"); }); + it("does not refresh todo-init titles for headless subagent sessions", async () => { + // Issue #5910: a subagent (agentKind "sub") in a non-interactive host has no + // operator-visible title, so a todo-init replan refresh only wastes a + // tiny-model LLM call. isInteractiveHost() defaults false under bun test. + await recreateSession({ "title.refreshOnReplan": true }, { agentKind: "sub" }); + await session.setSessionName("Old auto title", "auto"); + const priorUser: AgentMessage = { + role: "user", + content: "rework parser diagnostics", + timestamp: Date.now() - 1, + }; + session.agent.appendMessage(priorUser); + session.sessionManager.appendMessage(priorUser); + const completeSimpleMock = vi.spyOn(ai, "completeSimple"); + scriptedResponses = [ + createToolCallAssistantMessage("todo", { + op: "init", + list: [{ phase: "Parser", items: ["Replan parser diagnostics"] }], + }), + createAssistantMessage("todo initialized"), + ]; + + await session.prompt("replan parser diagnostics"); + + expect(completeSimpleMock).not.toHaveBeenCalled(); + expect(session.sessionManager.getSessionName()).toBe("Old auto title"); + }); + + it("refreshes todo-init titles for a subagent focusable in an interactive host", async () => { + // A live subagent selected from the Agent Hub renders its session name in + // the status line, so the interactive host must keep the replan refresh the + // user enabled — only headless hosts skip it (issue #5910 review follow-up). + const previousInteractiveHost = setInteractiveHost(true); + try { + await recreateSession({ "title.refreshOnReplan": true }, { agentKind: "sub" }); + await session.setSessionName("Old auto title", "auto"); + const priorUser: AgentMessage = { + role: "user", + content: "rework parser diagnostics", + timestamp: Date.now() - 1, + }; + session.agent.appendMessage(priorUser); + session.sessionManager.appendMessage(priorUser); + const completeSimpleMock = vi.spyOn(ai, "completeSimple").mockResolvedValue({ + stopReason: "stop", + content: [{ type: "text", text: "Parser diagnostics replan" }], + } as never); + scriptedResponses = [ + createToolCallAssistantMessage("todo", { + op: "init", + list: [{ phase: "Parser", items: ["Replan parser diagnostics"] }], + }), + createAssistantMessage("todo initialized"), + ]; + + const titleApplied = waitForSessionName("Parser diagnostics replan"); + await session.prompt("replan parser diagnostics"); + await titleApplied; + + expect(completeSimpleMock).toHaveBeenCalledTimes(1); + expect(session.sessionManager.getSessionName()).toBe("Parser diagnostics replan"); + } finally { + setInteractiveHost(previousInteractiveHost); + } + }); + it("does not refresh todo-init titles when title refresh on replan is disabled", async () => { const completeSimpleMock = vi.spyOn(ai, "completeSimple"); await session.setSessionName("Old auto title", "auto"); diff --git a/packages/utils/src/env.ts b/packages/utils/src/env.ts index 21619ae2d..b6f81dd74 100644 --- a/packages/utils/src/env.ts +++ b/packages/utils/src/env.ts @@ -207,6 +207,30 @@ export function setTerminalHeadless(headless: boolean): boolean { return previous; } +let interactiveHost = false; + +/** + * True when this process runs an interactive coding-agent host — the only + * context where the operator can browse the Agent Hub and focus a live + * subagent's session (`SessionFocusController`), so a subagent's session title + * can become operator-visible. Off by default (print/RPC/ACP/eval/SDK/`bun + * test` never render a focusable session tree); the interactive entrypoint + * flips it on with {@link setInteractiveHost}. + */ +export function isInteractiveHost(): boolean { + return interactiveHost; +} + +/** + * Set the interactive-host flag and return the previous value so callers can + * restore exact prior state. See {@link isInteractiveHost}. + */ +export function setInteractiveHost(interactive: boolean): boolean { + const previous = interactiveHost; + interactiveHost = interactive; + return previous; +} + /** * True when this code is running inside a `bun build --compile` standalone * binary. Detects via the embedded virtual-filesystem path markers