Agents: emit final assistant event for reply tags

This commit is contained in:
vinson 2026-01-30 17:22:41 +08:00
parent 3a85cb1833
commit dded462469
3 changed files with 69 additions and 0 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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<typeof subscribeEmbeddedPiSession>[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<string, unknown> | undefined)
.filter((value): value is Record<string, unknown> => 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 = {