From c0ceef7af2d660134ea2a61be3c309e763389a90 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:35:40 +0900 Subject: [PATCH] feat(coding-agent): re-inject eager task/todo nudges after compaction The first-message eager-task / eager-todo preludes are the oldest messages in a session, so auto-compaction summarizes them away and the agent silently loses the delegate-via-tasks / phased-todo guidance mid-work. Re-assert those reminders on the auto-continuation turn that follows a compaction. - Widen #createEagerTaskPrelude / #createEagerTodoPrelude to accept `string | undefined`; `undefined` (post-compaction) skips only the first-message and prompt-suffix gates, keeping the mode / agent-kind / plan-mode / surviving-todo / active-tool gates intact. - Reminder-only post-compaction: the todo nudge never attaches a forced `todo` tool_choice on the resumed turn (forcing a tool after a mid-turn compaction would override the agent's in-flight action). - Add #buildPostCompactionEagerNudges() and prepend its output on the single #scheduleAutoContinuePrompt continuation hook. All three call sites are willRetry-safe, so overflow/incomplete retry recoveries never carry the nudge. Op: extend --- packages/coding-agent/CHANGELOG.md | 4 + .../coding-agent/src/session/agent-session.ts | 68 +++- .../agent-session-eager-compaction.test.ts | 358 ++++++++++++++++++ 3 files changed, 415 insertions(+), 15 deletions(-) create mode 100644 packages/coding-agent/test/agent-session-eager-compaction.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ce58d520d..ac41b545c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -151,6 +151,10 @@ - Registered the `Advisor` group in the `model` settings tab so advisor settings render correctly in the settings panel. - Fixed Windows bash path handling so MSYS/Git-Bash drive aliases like `/d/project` and WSL-style `/mnt/d/project` normalize to native drive paths consistently across the bash tool cwd validation and brush filesystem builtins ([#2634](https://github.com/can1357/oh-my-pi/issues/2634)). +### Changed + +- The eager `task` (`task.eager: always`) and eager `todo` (`todo.eager: preferred`/`always`) hidden reminders now re-fire on the auto-continuation turn after a compaction (context-full / snapcompact / handoff / shake). Compaction summarizes away the first-message prelude, so the agent would otherwise silently lose the delegate-via-tasks / phased-todo guidance mid-work; the post-compaction todo nudge is reminder-only and never forces the `todo` tool onto the resumed turn. + ## [15.13.3] - 2026-06-15 ### Added diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index a5f30688c..39d9c1541 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2566,6 +2566,11 @@ export class AgentSession { #scheduleAutoContinuePrompt(generation: number): void { const continuePrompt = async () => { + // Compaction summarizes away the first-message eager preludes, so re-assert the + // delegate-via-tasks / phased-todo reminders on this auto-resumed turn. This runs + // at invocation (past the abort check below), so an aborted continuation queues + // nothing; scoped to this request via prependMessages, never the shared queue. + const eagerNudges = this.#buildPostCompactionEagerNudges(); await this.#promptWithMessage( { role: "developer", @@ -2574,7 +2579,10 @@ export class AgentSession { timestamp: Date.now(), }, autoContinuePrompt, - { skipPostPromptRecoveryWait: true }, + { + skipPostPromptRecoveryWait: true, + prependMessages: eagerNudges.length > 0 ? eagerNudges : undefined, + }, ); }; this.#schedulePostPromptTask( @@ -7774,7 +7782,9 @@ export class AgentSession { }; } - #createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice?: ToolChoice } | undefined { + #createEagerTodoPrelude( + promptText: string | undefined, + ): { message: AgentMessage; toolChoice?: ToolChoice } | undefined { const mode = this.settings.get("todo.eager"); const todosEnabled = this.settings.get("todo.enabled"); if (mode === "default" || !todosEnabled) { @@ -7791,14 +7801,18 @@ export class AgentSession { // Only inject on the first user message of the conversation. Subsequent user // turns must not receive the eager todo reminder — they often correct, clarify, // or redirect the prior task, and forcing a brand-new todo list there is wrong. - const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user"); - if (hasPriorUserMessage) { - return undefined; - } + // When `promptText` is undefined (post-compaction re-injection) there is no fresh + // user message to gate on, so skip the first-message and prompt-suffix checks. + if (promptText !== undefined) { + const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user"); + if (hasPriorUserMessage) { + return undefined; + } - const trimmedPromptText = promptText.trimEnd(); - if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) { - return undefined; + const trimmedPromptText = promptText.trimEnd(); + if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) { + return undefined; + } } // Must check the active tool set, not just the registry: tool discovery @@ -7821,8 +7835,10 @@ export class AgentSession { timestamp: Date.now(), }; // `preferred` suggests a todo list (reminder only); `always` also forces the - // `todo` tool on the first turn — the previous boolean-on behavior. - if (mode === "preferred") { + // `todo` tool on the first turn — the previous boolean-on behavior. Post-compaction + // re-injection (`promptText === undefined`) is always reminder-only: forcing a tool + // onto the auto-resumed turn would override the agent's in-flight action. + if (promptText === undefined || mode === "preferred") { return { message }; } const todoToolChoice = buildNamedToolChoice("todo", this.model); @@ -7840,7 +7856,7 @@ export class AgentSession { return { message, toolChoice: todoToolChoice }; } - #createEagerTaskPrelude(promptText: string): AgentMessage | undefined { + #createEagerTaskPrelude(promptText: string | undefined): AgentMessage | undefined { if (this.settings.get("task.eager") !== "always") return undefined; // Main agent only: subagents keep `task` active (the parent only filters `todo`), // so a salient delegate-reminder there would amplify nested fan-out. Gate on the @@ -7848,9 +7864,13 @@ export class AgentSession { // still gets the reminder. if (this.#agentKind === "sub") return undefined; if (this.#planModeState?.enabled) return undefined; - if (this.agent.state.messages.some(m => m.role === "user")) return undefined; - const trimmed = promptText.trimEnd(); - if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined; + // First-message-only gates are skipped post-compaction (`promptText === undefined`), + // where there is no fresh user message to suppress the reminder for. + if (promptText !== undefined) { + if (this.agent.state.messages.some(m => m.role === "user")) return undefined; + const trimmed = promptText.trimEnd(); + if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined; + } if (!this.getActiveToolNames().includes("task")) return undefined; return { role: "custom", @@ -7861,6 +7881,24 @@ export class AgentSession { timestamp: Date.now(), }; } + + /** + * Build the eager task/todo reminders to re-inject on the auto-continuation turn that + * follows a compaction. The first-message preludes are the oldest messages, so + * compaction summarizes them away and the agent silently loses the delegate-via-tasks + * and phased-todo guidance mid-work; this re-asserts them, reminder-only (the todo + * builder drops its forced tool_choice when `promptText` is undefined). Each builder + * still applies its own mode / agent-kind / plan-mode / tool-active / surviving-todo + * gates, so an empty array means nothing currently warrants a nudge. + */ + #buildPostCompactionEagerNudges(): AgentMessage[] { + const nudges: AgentMessage[] = []; + const todo = this.#createEagerTodoPrelude(undefined); + if (todo) nudges.push(todo.message); + const task = this.#createEagerTaskPrelude(undefined); + if (task) nudges.push(task); + return nudges; + } /** * Check if agent stopped with incomplete todos and prompt to continue. */ diff --git a/packages/coding-agent/test/agent-session-eager-compaction.test.ts b/packages/coding-agent/test/agent-session-eager-compaction.test.ts new file mode 100644 index 000000000..2402d7701 --- /dev/null +++ b/packages/coding-agent/test/agent-session-eager-compaction.test.ts @@ -0,0 +1,358 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as path from "node:path"; +import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; +import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; +import type { TextContent } from "@oh-my-pi/pi-ai"; +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 { 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 { TodoTool, type ToolSession, USER_TODO_EDIT_CUSTOM_TYPE } from "@oh-my-pi/pi-coding-agent/tools"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { z } from "zod/v4"; + +// Re-injecting eager preludes after compaction: the first-message preludes are the +// oldest messages, so compaction summarizes them away and the agent silently loses +// the delegate-via-tasks / phased-todo guidance. The post-compaction auto-continuation +// turn must carry the gated reminders again (reminder-only — never a forced tool_choice). + +const CONTINUE_MARKER = "Resume work on the user's most recent intent"; + +type ObservedPromptCall = { + toolChoice: string | undefined; + messageTexts: string[]; +}; + +type WaitForCall = (predicate: (call: ObservedPromptCall) => boolean) => Promise; + +type Harness = { + session: AgentSession; + observedCalls: ObservedPromptCall[]; + sessionManager: SessionManager; + // Resolves with the first provider call (already-seen or future) matching the predicate. + // Awaiting the real streamFn call avoids wall-clock polling for the async compaction path. + waitForCall: WaitForCall; +}; + +function isTextContentBlock(value: unknown): value is TextContent { + if (!value || typeof value !== "object") return false; + return (value as TextContent).type === "text" && typeof (value as TextContent).text === "string"; +} + +function getToolChoiceName(choice: unknown): string | undefined { + if (!choice) return undefined; + if (typeof choice === "string") return choice; + if (typeof choice !== "object" || !("type" in choice)) return undefined; + const toolChoice = choice as { type?: string; name?: string; function?: { name?: string } }; + if (toolChoice.type === "tool") return toolChoice.name; + if (toolChoice.type === "function") return toolChoice.name ?? toolChoice.function?.name; + return undefined; +} + +function getMessageText(message: AgentMessage): string { + if (!("content" in message)) return ""; + if (typeof message.content === "string") return message.content; + if (!Array.isArray(message.content)) return ""; + return message.content + .filter(isTextContentBlock) + .map(content => content.text) + .join("\n"); +} + +function createAssistantResponse(text: string) { + return { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-sonnet-4-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: Date.now(), + }; +} + +/** Short-circuit the LLM summary so compaction completes without a network call. */ +function stubCompaction(firstKeptEntryId?: string): void { + vi.spyOn(compactionModule, "compact").mockImplementation(async preparation => ({ + summary: "compacted", + shortSummary: undefined, + firstKeptEntryId: firstKeptEntryId ?? preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details: {}, + })); +} + +/** Emit a high-usage assistant turn to drive threshold (context-full) auto-compaction. */ +function emitHighUsageTurn(session: AgentSession): void { + const assistantMsg = { + role: "assistant" as const, + content: [{ type: "text" as const, text: "Done." }], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-sonnet-4-5", + stopReason: "stop" as const, + usage: { + input: 190_000, + output: 1_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 191_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); +} + +describe("AgentSession eager prelude re-injection after compaction", () => { + let tempDir: TempDir; + const cleanups: Array<() => Promise> = []; + + beforeEach(() => { + tempDir = TempDir.createSync("@pi-agent-session-eager-compaction-"); + cleanups.length = 0; + }); + + afterEach(async () => { + for (const cleanup of cleanups) await cleanup(); + cleanups.length = 0; + tempDir.removeSync(); + vi.restoreAllMocks(); + }); + + async function createHarness( + settingsOverride: Record = {}, + opts: { agentId?: string; agentKind?: "main" | "sub" } = {}, + ): Promise { + const observedCalls: ObservedPromptCall[] = []; + const waiters: Array<{ + predicate: (call: ObservedPromptCall) => boolean; + resolve: (call: ObservedPromptCall) => void; + }> = []; + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); + + const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${cleanups.length}.db`)); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`)); + const settings = Settings.isolated({ + "compaction.enabled": true, + "compaction.autoContinue": true, + "compaction.strategy": "context-full", + "task.eager": "always", + "todo.enabled": false, + "todo.eager": "default", + "todo.reminders": false, + ...settingsOverride, + }); + const sessionManager = SessionManager.inMemory(tempDir.path()); + + const mockTaskTool: AgentTool = { + name: "task", + label: "Task", + description: "Mock task tool", + parameters: z.object({}), + execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }), + }; + const mockBashTool: AgentTool = { + name: "bash", + label: "Bash", + description: "Mock bash tool", + parameters: z.object({}), + execute: async () => ({ content: [{ type: "text" as const, text: "ok" }] }), + }; + const todoEnabled = settings.get("todo.enabled") === true; + const toolSession: ToolSession = { + cwd: tempDir.path(), + hasUI: false, + getSessionFile: () => sessionManager.getSessionFile() ?? null, + getSessionSpawns: () => "*", + settings, + }; + const todoTool = todoEnabled ? new TodoTool(toolSession) : undefined; + const tools: AgentTool[] = todoTool + ? [todoTool as unknown as AgentTool, mockTaskTool, mockBashTool] + : [mockTaskTool, mockBashTool]; + + let session: AgentSession; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["Test"], tools, messages: [] }, + convertToLlm, + getToolChoice: () => session?.nextToolChoice(), + streamFn: (_model, context, options) => { + const call: ObservedPromptCall = { + toolChoice: getToolChoiceName(options?.toolChoice), + messageTexts: context.messages.map(message => getMessageText(message)), + }; + observedCalls.push(call); + for (let i = waiters.length - 1; i >= 0; i--) { + const waiter = waiters[i]; + if (waiter?.predicate(call)) { + waiter.resolve(call); + waiters.splice(i, 1); + } + } + const response = createAssistantResponse("done"); + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: response }); + stream.push({ type: "done", reason: "stop", message: response }); + }); + return stream; + }, + }); + + const toolRegistry = new Map([ + [mockTaskTool.name, mockTaskTool], + [mockBashTool.name, mockBashTool], + ]); + if (todoTool) toolRegistry.set(todoTool.name, todoTool as unknown as AgentTool); + + session = new AgentSession({ + agent, + sessionManager, + settings, + modelRegistry, + toolRegistry, + agentId: opts.agentId, + agentKind: opts.agentKind, + }); + + const waitForCall: WaitForCall = predicate => { + const existing = observedCalls.find(predicate); + if (existing) return Promise.resolve(existing); + const { promise, resolve } = Promise.withResolvers(); + waiters.push({ predicate, resolve }); + return promise; + }; + + cleanups.push(async () => { + await session.dispose(); + authStorage.close(); + }); + return { session, observedCalls, sessionManager, waitForCall }; + } + + /** Run the first prompt, drive a compaction, and resolve with the auto-continuation provider call. */ + async function runToContinuation(session: AgentSession, waitForCall: WaitForCall): Promise { + await session.prompt("refactor the parser across modules"); + emitHighUsageTurn(session); + return waitForCall(call => call.messageTexts.some(text => text.includes(CONTINUE_MARKER))); + } + + it("re-injects the eager task reminder on the auto-continuation turn (task.eager always)", async () => { + const { session, waitForCall } = await createHarness(); + stubCompaction(); + + const continuation = await runToContinuation(session, waitForCall); + + const reminder = continuation.messageTexts.find(text => text.includes("delegation is enabled")); + expect(reminder).toBeDefined(); + expect(reminder).toContain("`task`"); + // Reminder-only: the post-compaction nudge never forces a tool on the resumed turn. + expect(continuation.toolChoice).toBeUndefined(); + }); + + it("does not re-inject the eager task reminder when task.eager is default", async () => { + const { session, waitForCall } = await createHarness({ "task.eager": "default" }); + stubCompaction(); + + const continuation = await runToContinuation(session, waitForCall); + + expect(continuation.messageTexts.some(text => text.includes("delegation is enabled"))).toBe(false); + }); + + it("does not re-inject the eager task reminder when task.eager is preferred", async () => { + const { session, waitForCall } = await createHarness({ "task.eager": "preferred" }); + stubCompaction(); + + const continuation = await runToContinuation(session, waitForCall); + + expect(continuation.messageTexts.some(text => text.includes("delegation is enabled"))).toBe(false); + }); + + it("does not re-inject the eager task reminder for subagent sessions", async () => { + const { session, waitForCall } = await createHarness({}, { agentId: "SubAgent", agentKind: "sub" }); + stubCompaction(); + + const continuation = await runToContinuation(session, waitForCall); + + expect(continuation.messageTexts.some(text => text.includes("delegation is enabled"))).toBe(false); + }); + + it("does not re-inject the eager task reminder in plan mode", async () => { + const { session, waitForCall } = await createHarness(); + session.setPlanModeState({ enabled: true, planFilePath: path.join(tempDir.path(), "plan.md") }); + stubCompaction(); + + const continuation = await runToContinuation(session, waitForCall); + + expect(continuation.messageTexts.some(text => text.includes("delegation is enabled"))).toBe(false); + }); + + it("re-injects the eager todo reminder on the auto-continuation turn (todo.eager preferred)", async () => { + const { session, waitForCall } = await createHarness({ + "task.eager": "default", + "todo.enabled": true, + "todo.eager": "preferred", + }); + stubCompaction(); + + const continuation = await runToContinuation(session, waitForCall); + + expect(continuation.messageTexts.some(text => text.includes("Consider calling"))).toBe(true); + expect(continuation.toolChoice).toBeUndefined(); + }); + + it("re-injects the eager todo reminder reminder-only for todo.eager always (no forced tool)", async () => { + const { session, waitForCall } = await createHarness({ + "task.eager": "default", + "todo.enabled": true, + "todo.eager": "always", + }); + stubCompaction(); + + const continuation = await runToContinuation(session, waitForCall); + + // `always` keeps the strong forced wording in the reminder text... + expect(continuation.messageTexts.some(text => text.includes("You MUST call"))).toBe(true); + // ...but post-compaction never attaches the forced todo tool_choice. + expect(continuation.toolChoice).toBeUndefined(); + }); + + it("does not re-inject the eager todo reminder when todos survived compaction", async () => { + const { session, sessionManager, waitForCall } = await createHarness({ + "task.eager": "default", + "todo.enabled": true, + "todo.eager": "preferred", + }); + await session.prompt("refactor the parser across modules"); + // A surviving todo entry; pin firstKeptEntryId so compaction preserves it in the branch. + const todoEntryId = sessionManager.appendCustomEntry(USER_TODO_EDIT_CUSTOM_TYPE, { + phases: [{ name: "Work", tasks: [{ content: "do the thing", status: "pending" }] }], + }); + stubCompaction(todoEntryId); + + const continuationPromise = waitForCall(call => call.messageTexts.some(text => text.includes(CONTINUE_MARKER))); + emitHighUsageTurn(session); + const continuation = await continuationPromise; + + expect(session.getTodoPhases().length).toBeGreaterThan(0); + expect(continuation.messageTexts.some(text => text.includes("Consider calling"))).toBe(false); + expect(continuation.messageTexts.some(text => text.includes("You MUST call"))).toBe(false); + }); +});