diff --git a/CHANGELOG.md b/CHANGELOG.md index ec0fc3fb6..b8869f39d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ Status: stable. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- TUI: emit final assistant event when reply tags hide stream. (#4495) Thanks @ukeate. - Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway. - Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796) - Telegram: scope native skill commands to bound agent per bot. (#4360) Thanks @robhparker. diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 1f515e113..bf1fcd12b 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -176,6 +176,30 @@ export function handleMessageEnd( }); const text = ctx.stripBlockTags(rawText, { thinking: false, final: false }); + const { text: cleanedText, mediaUrls } = parseReplyDirectives(text); + const { text: previousCleanedText } = parseReplyDirectives(ctx.state.lastStreamedAssistant ?? ""); + if (cleanedText && cleanedText !== previousCleanedText) { + const deltaText = cleanedText.startsWith(previousCleanedText) + ? cleanedText.slice(previousCleanedText.length) + : cleanedText; + emitAgentEvent({ + runId: ctx.params.runId, + stream: "assistant", + data: { + text: cleanedText, + delta: deltaText, + mediaUrls: mediaUrls?.length ? mediaUrls : undefined, + }, + }); + void ctx.params.onAgentEvent?.({ + stream: "assistant", + data: { + text: cleanedText, + delta: deltaText, + mediaUrls: mediaUrls?.length ? mediaUrls : undefined, + }, + }); + } const rawThinking = ctx.state.includeReasoning || ctx.state.streamReasoning ? extractAssistantThinking(assistantMessage) || extractThinkingFromTaggedText(rawText) diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index ec38734ac..e1a5838d4 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -221,6 +221,50 @@ describe("subscribeEmbeddedPiSession", () => { expect(payloads[0]?.text).toBe("MEDIA:"); }); + it("emits agent events on message_end when reply tags hide final text", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const onAgentEvent = vi.fn(); + + subscribeEmbeddedPiSession({ + session: session as unknown as Parameters[0]["session"], + runId: "run", + onAgentEvent, + }); + + handler?.({ type: "message_start", message: { role: "assistant" } }); + handler?.({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { type: "text_delta", delta: "[[reply_to: 123" }, + }); + handler?.({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { type: "text_delta", delta: "]] Hello" }, + }); + + const assistantMessage = { + role: "assistant", + content: [{ type: "text", text: "[[reply_to: 123]] Hello" }], + } as AssistantMessage; + handler?.({ type: "message_end", message: assistantMessage }); + + const payloads = onAgentEvent.mock.calls + .map((call) => call[0]?.data as Record | undefined) + .filter((value): value is Record => Boolean(value)); + expect(payloads.length).toBeGreaterThan(0); + const last = payloads[payloads.length - 1]; + expect(last?.text).toBe("Hello"); + expect(last?.delta).toBe("Hello"); + }); + it("emits agent events when media arrives without text", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = {