diff --git a/extensions/guardian/message-cache.test.ts b/extensions/guardian/message-cache.test.ts index ee025d21c41..d1b93d50d8c 100644 --- a/extensions/guardian/message-cache.test.ts +++ b/extensions/guardian/message-cache.test.ts @@ -556,12 +556,12 @@ describe("message-cache", () => { expect(isSystemTrigger("nonexistent")).toBe(false); }); - it("preserves isSystemTrigger when subsequent llm_input has no prompt", () => { + it("stays true when heartbeat is in historyMessages on subsequent llm_input", () => { // Heartbeat fires with prompt → isSystemTrigger=true updateCache("s1", [], "heartbeat", 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); - // Agent loop continues without prompt (tool result processed) → should preserve true + // Agent loop continues — heartbeat is now in historyMessages updateCache("s1", [{ role: "user", content: "heartbeat" }], undefined, 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); }); @@ -570,11 +570,15 @@ describe("message-cache", () => { updateCache("s1", [], "heartbeat", 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(true); - // Real user message arrives → should reset to false + // Real user message arrives — now the last user message in history is the real one updateCache( "s1", - [{ role: "user", content: "heartbeat" }], - "Deploy my project", + [ + { role: "user", content: "heartbeat" }, + { role: "assistant", content: "HEARTBEAT_OK" }, + { role: "user", content: "Deploy my project" }, + ], + undefined, 3, NO_FILTER, ); @@ -586,6 +590,27 @@ describe("message-cache", () => { updateCache("s1", [], undefined, 3, NO_FILTER); expect(isSystemTrigger("s1")).toBe(false); }); + + it("detects heartbeat from last user message in historyMessages when currentPrompt is undefined", () => { + // Heartbeat prompt arrives via historyMessages, not currentPrompt + const heartbeatPrompt = + "Read HEARTBEAT.md if it exists (workspace context). If nothing needs attention, reply HEARTBEAT_OK."; + updateCache("s1", [{ role: "user", content: heartbeatPrompt }], undefined, 3, NO_FILTER); + expect(isSystemTrigger("s1")).toBe(true); + }); + + it("detects heartbeat from historyMessages even on first llm_input (no existing entry)", () => { + updateCache("s1", [{ role: "user", content: "heartbeat" }], undefined, 3, NO_FILTER); + expect(isSystemTrigger("s1")).toBe(true); + }); + + it("resets when historyMessages last user message is not a system trigger", () => { + updateCache("s1", [{ role: "user", content: "heartbeat" }], undefined, 3, NO_FILTER); + expect(isSystemTrigger("s1")).toBe(true); + + updateCache("s1", [{ role: "user", content: "Deploy my project" }], undefined, 3, NO_FILTER); + expect(isSystemTrigger("s1")).toBe(false); + }); }); describe("getRecentTurns filters system turns", () => { diff --git a/extensions/guardian/message-cache.ts b/extensions/guardian/message-cache.ts index 371c17330bf..a236e4bf35f 100644 --- a/extensions/guardian/message-cache.ts +++ b/extensions/guardian/message-cache.ts @@ -49,14 +49,13 @@ export function updateCache( contextTools, totalTurnsProcessed: totalTurns, lastSummarizedTurnCount: existing?.lastSummarizedTurnCount ?? 0, - // Preserve isSystemTrigger when currentPrompt is empty (agent loop continuation). - // During a heartbeat cycle, llm_input fires multiple times: first with the - // heartbeat prompt (isSystemTrigger=true), then without a prompt as the agent - // loop continues after tool results. Without preservation, the flag resets to - // false and heartbeat tool calls reach the guardian unnecessarily. - isSystemTrigger: currentPrompt - ? isSystemTriggerPrompt(currentPrompt) - : (existing?.isSystemTrigger ?? false), + // Detect system triggers from both currentPrompt AND the last user message + // in historyMessages. Heartbeats may arrive via either path depending on + // the agent loop stage (currentPrompt on first llm_input, historyMessages + // on subsequent continuations after tool results). + isSystemTrigger: + isSystemTriggerPrompt(currentPrompt) || + isSystemTriggerPrompt(getLastUserMessageText(historyMessages)), agentSystemPrompt: existing?.agentSystemPrompt, updatedAt: Date.now(), }); @@ -279,6 +278,17 @@ function filterSystemTurns(turns: ConversationTurn[]): ConversationTurn[] { }); } +/** Extract text from the last user message in the history array. */ +function getLastUserMessageText(historyMessages: unknown[]): string | undefined { + for (let i = historyMessages.length - 1; i >= 0; i--) { + const msg = historyMessages[i]; + if (isMessageLike(msg) && msg.role === "user") { + return extractTextContent(msg.content) || undefined; + } + } + return undefined; +} + /** Count user messages in the history array. */ function countUserMessages(historyMessages: unknown[]): number { let count = 0;