Merge PR #5911: fix(subagent): skip session title generation for headless subagents (@roboomp)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user