From 4cb2a9203739ade2fad0e81c5c139e6c112a9abe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 5 Dec 2025 22:52:21 +0000 Subject: [PATCH] fix: avoid echoing prompts when rpc returns empty --- CHANGELOG.md | 1 + src/auto-reply/command-reply.test.ts | 37 ++++++++++++++++++++++++++++ src/auto-reply/command-reply.ts | 16 ++++++++++-- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e357465..ad70a79c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Tau RPC timeout is now inactivity-based (5m without events) and error messages show seconds only. - 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. ## 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 ac0f8f955..4007b4b28 100644 --- a/src/auto-reply/command-reply.test.ts +++ b/src/auto-reply/command-reply.test.ts @@ -111,6 +111,43 @@ describe("runCommandReply (pi)", () => { ).toBe(false); }); + it("does not echo the user's prompt when the agent returns no assistant text", async () => { + const rpcMock = mockPiRpc({ + stdout: [ + '{"type":"agent_start"}', + '{"type":"turn_start"}', + '{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}', + '{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}', + // assistant emits nothing useful + '{"type":"agent_end"}', + ].join("\n"), + stderr: "", + code: 0, + }); + + const { payloads } = await runCommandReply({ + reply: { + mode: "command", + command: ["pi", "{{Body}}"], + agent: { kind: "pi" }, + }, + templatingCtx: { ...noopTemplateCtx, Body: "hello", BodyStripped: "hello" }, + 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("hello"); + }); + 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 ffc5c6541..6f796d342 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -32,6 +32,7 @@ function stripRpcNoise(raw: string): string { const type = evt?.type; const msg = evt?.message ?? evt?.assistantMessageEvent; const msgType = msg?.type; + const role = msg?.role; // RPC streaming emits one message_update per delta; skip them to avoid flooding fallbacks. if (type === "message_update") continue; @@ -40,6 +41,11 @@ function stripRpcNoise(raw: string): string { if (type === "message_update" && msgType === "toolcall_delta") continue; if (type === "input_audio_buffer.append") continue; + // Keep only assistant/tool messages; drop agent_start/turn_start/user/etc. + const isAssistant = role === "assistant"; + const isToolRole = typeof role === "string" && role.toLowerCase().includes("tool"); + if (!isAssistant && !isToolRole) continue; + // Ignore assistant messages that have no text content (pure toolcall scaffolding). if (msg?.role === "assistant" && Array.isArray(msg?.content)) { const hasText = msg.content.some( @@ -770,9 +776,15 @@ export async function runCommandReply( extractRpcAssistantText(trimmed) ?? extractAssistantTextLoosely(trimmed) ?? trimmed; - if (replyItems.length === 0 && fallbackText && !hasParsedContent) { + const promptEcho = + fallbackText && + (fallbackText === (templatingCtx.Body ?? "") || + fallbackText === (templatingCtx.BodyStripped ?? "")); + const safeFallbackText = promptEcho ? undefined : fallbackText; + + if (replyItems.length === 0 && safeFallbackText && !hasParsedContent) { const { text: cleanedText, mediaUrls: mediaFound } = - splitMediaFromOutput(fallbackText); + splitMediaFromOutput(safeFallbackText); if (cleanedText || mediaFound?.length) { replyItems.push({ text: cleanedText,