fix: start typing on partial deltas

This commit is contained in:
Peter Steinberger
2026-01-12 22:16:29 +00:00
parent cb35db0c7e
commit f4ab057807
2 changed files with 47 additions and 24 deletions

View File

@@ -145,6 +145,23 @@ describe("runReplyAgent typing (heartbeat)", () => {
expect(typing.startTypingLoop).toHaveBeenCalled();
});
it("signals typing even without consumer partial handler", async () => {
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams) => {
await params.onPartialReply?.({ text: "hi" });
return { payloads: [{ text: "final" }], meta: {} };
},
);
const { run, typing } = createMinimalRun({
typingMode: "message",
});
await run();
expect(typing.startTypingOnText).toHaveBeenCalledWith("hi");
expect(typing.startTypingLoop).not.toHaveBeenCalled();
});
it("never signals typing for heartbeat runs", async () => {
const onPartialReply = vi.fn();
runEmbeddedPiAgentMock.mockImplementationOnce(

View File

@@ -547,6 +547,28 @@ export async function runReplyAgent(params: {
const allowPartialStream = !(
followupRun.run.reasoningLevel === "stream" && opts?.onReasoningStream
);
const handlePartialForTyping = async (
payload: ReplyPayload,
): Promise<string | undefined> => {
if (!allowPartialStream) return undefined;
let text = payload.text;
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
if (stripped.shouldSkip && (payload.mediaUrls?.length ?? 0) === 0) {
return undefined;
}
text = stripped.text;
}
if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) return undefined;
await typingSignals.signalTextDelta(text);
return text;
};
const fallbackResult = await runWithModelFallback({
cfg: followupRun.run.config,
provider: followupRun.run.provider,
@@ -641,31 +663,15 @@ export async function runReplyAgent(params: {
blockReplyBreak: resolvedBlockStreamingBreak,
blockReplyChunking,
onPartialReply:
opts?.onPartialReply && allowPartialStream
allowPartialStream
? async (payload) => {
let text = payload.text;
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose(
"Stripped stray HEARTBEAT_OK token from reply",
);
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) return;
await typingSignals.signalTextDelta(text);
await opts.onPartialReply?.({
text,
const textForTyping = await handlePartialForTyping(
payload,
);
if (!opts?.onPartialReply || textForTyping === undefined)
return;
await opts.onPartialReply({
text: textForTyping,
mediaUrls: payload.mediaUrls,
});
}