Merge PR #5911: fix(subagent): skip session title generation for headless subagents (@roboomp)

This commit is contained in:
can1357
2026-07-18 19:57:44 +02:00
5 changed files with 116 additions and 5 deletions
+1
View File
@@ -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
+5
View File
@@ -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);
@@ -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;
@@ -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<string, unknown> = {}): Promise<void> {
async function createSession(
settingsOverride: Record<string, unknown> = {},
sessionOverride: Partial<AgentSessionConfig> = {},
): Promise<void> {
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<string, unknown> = {}): Promise<void> {
async function recreateSession(
settingsOverride: Record<string, unknown> = {},
sessionOverride: Partial<AgentSessionConfig> = {},
): Promise<void> {
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<void> {
@@ -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: "<title>Parser diagnostics replan</title>" }],
} 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");
+24
View File
@@ -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