import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, test, vi } from "vitest"; import type { AgentStatus } from "../src/core/session-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; import type { ActiveSessionState } from "../src/modes/daemon/active-session-state.js"; import { buildStatusContext, DaemonSessionSummarizer, parseAgentStatusResponse, } from "../src/modes/daemon/daemon-session-summarizer.js"; function userMessage(text: string): AgentMessage { return { role: "user", content: [{ type: "text", text }], timestamp: 0 } as unknown as AgentMessage; } function assistantMessage(text: string, tools: string[] = []): AgentMessage { const content = [{ type: "text", text }, ...tools.map((name) => ({ type: "tool_use", name, id: name, input: {} }))]; return { role: "assistant", content, timestamp: 0 } as unknown as AgentMessage; } describe("daemon session summarizer", () => { describe("parseAgentStatusResponse", () => { test("parses recap and completion verdict for an idle session", () => { const result = parseAgentStatusResponse( "Added the API reference page\nCOMPLETED", false, ); expect(result).toEqual({ summary: "Added the API reference page", taskState: "completed" }); }); test("maps NEEDS_INPUT for idle sessions", () => { const result = parseAgentStatusResponse( "Asked which database to target\nNEEDS_INPUT", false, ); expect(result?.taskState).toBe("needs_input"); }); test("omits the verdict while working and ignores any status tag", () => { const result = parseAgentStatusResponse( "Refactoring token validation\nCOMPLETED", true, ); expect(result).toEqual({ summary: "Refactoring token validation" }); }); test("falls back to needs_input on a missing or unrecognized idle verdict", () => { expect( parseAgentStatusResponse("Doing something\nMAYBE", false)?.taskState, ).toBe("needs_input"); expect(parseAgentStatusResponse("Doing something", false)?.taskState).toBe("needs_input"); }); test("ignores narration outside the tags and never leaks free-form text", () => { // A chatty/reasoning model that narrates instead of using tags yields no recap. expect(parseAgentStatusResponse("Investigating the failing test.", true)).toBeUndefined(); expect( parseAgentStatusResponse( "Recap: . So: Curating a niche list of Muon optimizer papers", true, ), ).toEqual({ summary: "Curating a niche list of Muon optimizer papers" }); }); test("ignores reasoning prose around the tags", () => { const text = "Let me decide. The agent finished editing.\nUpdated the login handler\nCOMPLETED"; expect(parseAgentStatusResponse(text, false)).toEqual({ summary: "Updated the login handler", taskState: "completed", }); }); test("rejects an echoed prompt template", () => { const echoed = "a present-tense clause, at most 12 words, no trailing period\nCOMPLETED"; expect(parseAgentStatusResponse(echoed, true)).toBeUndefined(); }); test("returns undefined when no recap tag is present", () => { expect(parseAgentStatusResponse("", false)).toBeUndefined(); expect(parseAgentStatusResponse("COMPLETED", false)).toBeUndefined(); }); test("drops chain-of-thought that falls outside the closing recap tag", () => { const text = "Sending SSH auth retry to tcg-autoresearch-rl That's 5 words? Count: Sending(1) SSH(2) = 6 words.\nNEEDS_INPUT"; expect(parseAgentStatusResponse(text, true)).toEqual({ summary: "Sending SSH auth retry to tcg-autoresearch-rl", }); }); test("rejects a recap body that is nothing but counting artifacts", () => { expect(parseAgentStatusResponse("(1) word(2) count(3) = 3 words", true)).toBeUndefined(); }); test("rejects a rambling recap that blows past the word ceiling", () => { const text = "this is a very long rambling sentence that just keeps going and going well past any reasonable recap length"; expect(parseAgentStatusResponse(text, true)).toBeUndefined(); }); test("strips wrapping quotes the model adds around the recap", () => { const text = '"Wiring the recap line"\nCOMPLETED'; expect(parseAgentStatusResponse(text, false)).toEqual({ summary: "Wiring the recap line", taskState: "completed", }); }); test("ignores an open recap tag with no close", () => { expect( parseAgentStatusResponse("Editing the parser\nNEEDS_INPUT", true), ).toBeUndefined(); }); test("takes the last recap tag when a draft is corrected", () => { const text = "Draft recap\nFinal corrected recap"; expect(parseAgentStatusResponse(text, true)).toEqual({ summary: "Final corrected recap" }); }); test("takes the last status tag when a draft is corrected", () => { const text = "Editing the parser\nNEEDS_INPUT\nCOMPLETED"; expect(parseAgentStatusResponse(text, false)?.taskState).toBe("completed"); }); test("normalizes unicode angle-bracket lookalikes around the tags", () => { // The model sometimes emits › ‹ instead of > < ; normalize so the tag still parses. const text = "‹recap›Curating a niche list of Muon optimizer papers‹/recap›"; expect(parseAgentStatusResponse(text, true)).toEqual({ summary: "Curating a niche list of Muon optimizer papers", }); }); }); describe("buildStatusContext", () => { test("includes the agent state and the trailing conversation with tool names", () => { const context = buildStatusContext( [userMessage("add a login endpoint"), assistantMessage("Editing the router", ["Edit", "Bash"])], true, ); expect(context).toContain("working"); expect(context).toContain("user: add a login endpoint"); expect(context).toContain("assistant: Editing the router [tools: Edit, Bash]"); }); test("marks idle sessions as finished", () => { expect(buildStatusContext([userMessage("hi")], false)).toContain("idle (finished its turn)"); }); test("only keeps the most recent messages", () => { const messages = Array.from({ length: 20 }, (_, i) => userMessage(`message ${i}`)); const context = buildStatusContext(messages, false); expect(context).toContain("message 19"); expect(context).not.toContain("message 0\n"); }); }); describe("status change notification", () => { function makeState(options: { messages: AgentMessage[]; isSessionActive: boolean; summaryState?: AgentStatus; persistedStatus?: AgentStatus; appendAgentStatus?: (status: AgentStatus) => void; getLeafId?: () => string | null; }): ActiveSessionState { return { activeSessionId: "active-1", summaryState: options.summaryState, runtime: { session: { isSessionActive: options.isSessionActive, messages: options.messages, modelRegistry: {}, settingsManager: SettingsManager.inMemory(), state: { streamingMessage: undefined }, sessionManager: { appendAgentStatus: options.appendAgentStatus ?? (() => {}), getLatestAgentStatus: () => options.persistedStatus, getLeafId: options.getLeafId ?? (() => null), }, }, }, } as unknown as ActiveSessionState; } async function settle(state: ActiveSessionState, generated: { summary: string; taskState?: "needs_input" }) { const onStatusChanged = vi.fn(); const summarizer = new DaemonSessionSummarizer( () => [state], onStatusChanged, async () => generated, ); await (summarizer as unknown as { summarize(state: ActiveSessionState): Promise }).summarize(state); return onStatusChanged; } test("an idle settle with unchanged verdict text still notifies: its currency drives the roster", async () => { const previous: AgentStatus = { summary: "Working on it", taskState: "needs_input", basedOnMessageCount: 1 }; const state = makeState({ messages: [userMessage("hi"), userMessage("more")], isSessionActive: false, summaryState: previous, }); const onStatusChanged = await settle(state, { summary: "Working on it", taskState: "needs_input" }); expect(state.summaryState?.basedOnMessageCount).toBe(2); expect(onStatusChanged).toHaveBeenCalledOnce(); }); function failingIdleSetup( stateOptions: Parameters[0], generateFn: () => Promise = async () => undefined, ) { const state = makeState(stateOptions); const generate = vi.fn(generateFn); const summarizer = new DaemonSessionSummarizer(() => [state], undefined, generate); const internal = summarizer as unknown as { summarize(state: ActiveSessionState): Promise; failedIdleGenerations: Map; }; return { state, generate, summarizer, internal }; } // Warmup pins the ceiling itself: repeated failing idle sweeps stop paying // after three attempts and never persist the fabricated in-memory fallback. test.each([ { rearm: "branch navigation moving the leaf at the same length", trigger: (leaf: { id: string }) => { leaf.id = "leaf-b"; }, }, { rearm: "the backoff elapsing so external failures recover", trigger: () => vi.setSystemTime(Date.now() + 31 * 60_000), }, ])("the exhausted idle retry ceiling re-arms on $rearm", async ({ trigger }) => { vi.useFakeTimers(); try { const appendAgentStatus = vi.fn(); const leaf = { id: "leaf-a" }; const { state, generate, internal } = failingIdleSetup({ messages: [userMessage("hi")], isSessionActive: false, appendAgentStatus, getLeafId: () => leaf.id, }); for (let sweep = 0; sweep < 5; sweep++) { await internal.summarize(state); } expect(generate).toHaveBeenCalledTimes(3); expect(appendAgentStatus).not.toHaveBeenCalled(); expect(state.summaryState).toEqual({ summary: "", taskState: "needs_input", basedOnMessageCount: 1 }); trigger(leaf); await internal.summarize(state); expect(generate).toHaveBeenCalledTimes(4); expect(appendAgentStatus).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); test("a forget() during an in-flight idle generation leaves no failure record behind", async () => { let release!: () => void; const gate = new Promise((resolveGate) => { release = resolveGate; }); const { state, summarizer, internal } = failingIdleSetup( { messages: [userMessage("hi")], isSessionActive: false }, async () => { await gate; return undefined; }, ); const pass = internal.summarize(state); summarizer.forget("active-1"); release(); await pass; expect(internal.failedIdleGenerations.size).toBe(0); }); test("an idle re-settle matching the latest persisted status appends nothing", async () => { const appendAgentStatus = vi.fn(); const persisted: AgentStatus = { summary: "Awaiting review", taskState: "needs_input", basedOnMessageCount: 1, }; const state = makeState({ messages: [userMessage("hi")], isSessionActive: false, persistedStatus: persisted, appendAgentStatus, }); const onStatusChanged = vi.fn(); const summarizer = new DaemonSessionSummarizer( () => [state], onStatusChanged, async () => ({ summary: "Awaiting review", taskState: "needs_input" as const }), ); await (summarizer as unknown as { summarize(state: ActiveSessionState): Promise }).summarize(state); expect(appendAgentStatus).not.toHaveBeenCalled(); expect(onStatusChanged).toHaveBeenCalledOnce(); expect(state.summaryState).toEqual(persisted); }); test("a working refresh with unchanged text stays quiet", async () => { const previous: AgentStatus = { summary: "Working on it", taskState: "needs_input", basedOnMessageCount: 2 }; const state = makeState({ messages: [userMessage("hi"), userMessage("more")], isSessionActive: true, summaryState: previous, }); const onStatusChanged = await settle(state, { summary: "Working on it" }); expect(onStatusChanged).not.toHaveBeenCalled(); }); }); });