From b00197b56823c816780abb1b3ada432acb76a90b Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 17 Jul 2026 20:42:08 +0000 Subject: [PATCH 1/2] fix(subagent): skip session title generation for headless subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subagent sessions run `todo init` per the eager-todo prelude, which triggered `#scheduleReplanTitleRefresh()` and a tiny-model title generation call. The result is written to JSONL but never displayed — subagents surface their registry id and generated task label, not a session title. Short-circuit `#scheduleReplanTitleRefresh()` when `#agentKind === "sub"`. Uses the session-level subagent marker rather than `hasUI` so print/RPC top-level sessions keep persisting their auto title for `--resume`. Fixes #5910 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/session/agent-session.ts | 5 +++ .../test/agent-session-eager-todo.test.ts | 42 +++++++++++++++++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ad20be261..dc7191977 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed subagent (task) sessions triggering an unnecessary tiny-model session-title generation call on `todo init`; headless subagent sessions have no operator-visible title and now skip the replan title refresh ([#5910](https://github.com/can1357/oh-my-pi/issues/5910)). + ## [17.0.3] - 2026-07-17 ### Changed diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index aeb4a2e49..1caf4bf86 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -9574,6 +9574,11 @@ export class AgentSession { } #scheduleReplanTitleRefresh(): void { + // Subagent sessions have no operator-visible title — the tree shows their + // registry id and generated task label — so a todo-init replan refresh would + // only burn a tiny-model call whose result lands in JSONL and is never shown + // (issue #5910). + if (this.#agentKind === "sub") 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..98911b7b9 100644 --- a/packages/coding-agent/test/agent-session-eager-todo.test.ts +++ b/packages/coding-agent/test/agent-session-eager-todo.test.ts @@ -7,7 +7,7 @@ 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"; @@ -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,33 @@ 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: subagent sessions (agentKind "sub") have no visible session + // title, so a todo-init replan refresh only wastes a tiny-model LLM call. + 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("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"); From 53a3aef39a12add1f81367ed7f8b160b05f59c3c Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 17 Jul 2026 21:10:58 +0000 Subject: [PATCH 2/2] fix(subagent): keep title refresh for focusable subagents Review follow-up: a live subagent focused from the Agent Hub renders its session name in the status line (session_name segment reads sessionManager.getSessionName()), so the blanket agentKind === "sub" skip made the user-enabled title.refreshOnReplan silently ineffective and left focused subagents untitled after their first todo replan. Focus only exists in an interactive host, and subagents run in-process, so gate the skip on a process-global interactive-host flag: subagents skip the replan title refresh only in non-interactive hosts (print/RPC/ACP/eval/SDK/ CI) where no session tree is focusable. The interactive entrypoint declares the host via setInteractiveHost(isInteractive); the flag defaults false, so bun test and headless embedders keep the optimization without leaking state. Fixes #5910 --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/main.ts | 5 +++ .../coding-agent/src/session/agent-session.ts | 13 +++--- .../test/agent-session-eager-todo.test.ts | 45 +++++++++++++++++-- packages/utils/src/env.ts | 24 ++++++++++ 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dc7191977..bce809c75 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed subagent (task) sessions triggering an unnecessary tiny-model session-title generation call on `todo init`; headless subagent sessions have no operator-visible title and now skip the replan title refresh ([#5910](https://github.com/can1357/oh-my-pi/issues/5910)). +- 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 e47d64e6e..e78d446a1 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"; @@ -1158,6 +1159,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 1caf4bf86..d9c4f4881 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -137,6 +137,7 @@ import { getInstallId, isBunTestRuntime, isEnoent, + isInteractiveHost, logger, postmortem, prompt, @@ -9574,11 +9575,13 @@ export class AgentSession { } #scheduleReplanTitleRefresh(): void { - // Subagent sessions have no operator-visible title — the tree shows their - // registry id and generated task label — so a todo-init replan refresh would - // only burn a tiny-model call whose result lands in JSONL and is never shown - // (issue #5910). - if (this.#agentKind === "sub") return; + // 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 98911b7b9..8a4276fa7 100644 --- a/packages/coding-agent/test/agent-session-eager-todo.test.ts +++ b/packages/coding-agent/test/agent-session-eager-todo.test.ts @@ -13,7 +13,7 @@ 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"; @@ -384,8 +384,9 @@ describe("AgentSession eager todo enforcement", () => { }); it("does not refresh todo-init titles for headless subagent sessions", async () => { - // Issue #5910: subagent sessions (agentKind "sub") have no visible session - // title, so a todo-init replan refresh only wastes a tiny-model LLM call. + // 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 = { @@ -410,6 +411,44 @@ describe("AgentSession eager todo enforcement", () => { 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