From d7a188fb34f3338583b7a56401e62c094b2a0189 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Dec 2025 22:56:07 +0000 Subject: [PATCH] fix: broaden prompt-echo guard and add heartbeat directive test --- CHANGELOG.md | 1 + src/auto-reply/command-reply.test.ts | 41 ++++++++++++++++++++++++++++ src/auto-reply/command-reply.ts | 35 ++++++++++++++++-------- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad70a79c5..1980bfad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent. - Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements. - RPC fallbacks no longer echo the user's prompt (e.g., pasting a link) when the agent returns no assistant text. +- Heartbeat prompts with `/think` no longer send directive acks; heartbeat replies stay silent on settings. ## 1.4.1 — 2025-12-04 diff --git a/src/auto-reply/command-reply.test.ts b/src/auto-reply/command-reply.test.ts index 4007b4b28..bc7c49868 100644 --- a/src/auto-reply/command-reply.test.ts +++ b/src/auto-reply/command-reply.test.ts @@ -148,6 +148,47 @@ describe("runCommandReply (pi)", () => { expect(payloads?.[0]?.text).not.toContain("hello"); }); + it("does not echo the prompt even when the fallback text matches after stripping prefixes", async () => { + const rpcMock = mockPiRpc({ + stdout: [ + '{"type":"agent_start"}', + '{"type":"turn_start"}', + '{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"[Dec 5 22:52] https://example.com"}]}}', + '{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"[Dec 5 22:52] https://example.com"}]}}', + // No assistant content + '{"type":"agent_end"}', + ].join("\n"), + stderr: "", + code: 0, + }); + + const { payloads } = await runCommandReply({ + reply: { + mode: "command", + command: ["pi", "{{Body}}"], + agent: { kind: "pi" }, + }, + templatingCtx: { + ...noopTemplateCtx, + Body: "[Dec 5 22:52] https://example.com", + BodyStripped: "[Dec 5 22:52] https://example.com", + }, + sendSystemOnce: false, + isNewSession: true, + isFirstTurnInSession: true, + systemSent: false, + timeoutMs: 1000, + timeoutSeconds: 1, + commandRunner: vi.fn(), + enqueue: enqueueImmediate, + }); + + expect(rpcMock).toHaveBeenCalledOnce(); + expect(payloads?.length).toBe(1); + expect(payloads?.[0]?.text).toMatch(/no output/i); + expect(payloads?.[0]?.text).not.toContain("example.com"); + }); + it("adds session args and --continue when resuming", async () => { const rpcMock = mockPiRpc({ stdout: diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index 6f796d342..b1c01a9d5 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -22,6 +22,14 @@ import { } from "./tool-meta.js"; import type { ReplyPayload } from "./types.js"; +function stripStructuralPrefixes(text: string): string { + return text + .replace(/\[[^\]]+\]\s*/g, "") + .replace(/^[ \t]*[A-Za-z0-9+()\-_. ]+:\s*/gm, "") + .replace(/\s+/g, " ") + .trim(); +} + function stripRpcNoise(raw: string): string { // Drop rpc streaming scaffolding (toolcall deltas, audio buffer events) before parsing. const lines = raw.split(/\n+/); @@ -771,18 +779,23 @@ export async function runCommandReply( } // If parser gave nothing, fall back to best-effort assistant text (prefers RPC deltas). - const fallbackText = - rpcAssistantText ?? - extractRpcAssistantText(trimmed) ?? - extractAssistantTextLoosely(trimmed) ?? - trimmed; - const promptEcho = - fallbackText && - (fallbackText === (templatingCtx.Body ?? "") || - fallbackText === (templatingCtx.BodyStripped ?? "")); - const safeFallbackText = promptEcho ? undefined : fallbackText; + const fallbackText = + rpcAssistantText ?? + extractRpcAssistantText(trimmed) ?? + extractAssistantTextLoosely(trimmed) ?? + trimmed; + const normalize = (s?: string) => + stripStructuralPrefixes((s ?? "").trim()).toLowerCase(); + const bodyNorm = normalize(templatingCtx.Body ?? templatingCtx.BodyStripped); + const fallbackNorm = normalize(fallbackText); + const promptEcho = + fallbackText && + (fallbackText === (templatingCtx.Body ?? "") || + fallbackText === (templatingCtx.BodyStripped ?? "") || + (bodyNorm.length > 0 && bodyNorm === fallbackNorm)); + const safeFallbackText = promptEcho ? undefined : fallbackText; - if (replyItems.length === 0 && safeFallbackText && !hasParsedContent) { + if (replyItems.length === 0 && safeFallbackText && !hasParsedContent) { const { text: cleanedText, mediaUrls: mediaFound } = splitMediaFromOutput(safeFallbackText); if (cleanedText || mediaFound?.length) {