fix: start typing on message start

This commit is contained in:
Peter Steinberger
2026-01-13 04:32:28 +00:00
parent 0ba60ff69c
commit d4c205f8e1
6 changed files with 47 additions and 8 deletions

View File

@@ -1383,6 +1383,7 @@ export async function runEmbeddedPiAgent(params: {
text?: string; text?: string;
mediaUrls?: string[]; mediaUrls?: string[];
}) => void | Promise<void>; }) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onBlockReply?: (payload: { onBlockReply?: (payload: {
text?: string; text?: string;
mediaUrls?: string[]; mediaUrls?: string[];
@@ -1774,6 +1775,7 @@ export async function runEmbeddedPiAgent(params: {
blockReplyBreak: params.blockReplyBreak, blockReplyBreak: params.blockReplyBreak,
blockReplyChunking: params.blockReplyChunking, blockReplyChunking: params.blockReplyChunking,
onPartialReply: params.onPartialReply, onPartialReply: params.onPartialReply,
onAssistantMessageStart: params.onAssistantMessageStart,
onAgentEvent: params.onAgentEvent, onAgentEvent: params.onAgentEvent,
enforceFinalTag: params.enforceFinalTag, enforceFinalTag: params.enforceFinalTag,
}); });

View File

@@ -146,7 +146,7 @@ function extractMessagingToolSend(
: undefined; : undefined;
} }
export function subscribeEmbeddedPiSession(params: { export type SubscribeEmbeddedPiSessionParams = {
session: AgentSession; session: AgentSession;
runId: string; runId: string;
verboseLevel?: "off" | "on"; verboseLevel?: "off" | "on";
@@ -173,12 +173,17 @@ export function subscribeEmbeddedPiSession(params: {
text?: string; text?: string;
mediaUrls?: string[]; mediaUrls?: string[];
}) => void | Promise<void>; }) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onAgentEvent?: (evt: { onAgentEvent?: (evt: {
stream: string; stream: string;
data: Record<string, unknown>; data: Record<string, unknown>;
}) => void; }) => void;
enforceFinalTag?: boolean; enforceFinalTag?: boolean;
}) { };
export function subscribeEmbeddedPiSession(
params: SubscribeEmbeddedPiSessionParams,
) {
const assistantTexts: string[] = []; const assistantTexts: string[] = [];
const toolMetas: Array<{ toolName?: string; meta?: string }> = []; const toolMetas: Array<{ toolName?: string; meta?: string }> = [];
const toolMetaById = new Map<string, string | undefined>(); const toolMetaById = new Map<string, string | undefined>();
@@ -492,6 +497,8 @@ export function subscribeEmbeddedPiSession(params: {
// may deliver late text_end updates after message_end, which would // may deliver late text_end updates after message_end, which would
// otherwise re-trigger block replies. // otherwise re-trigger block replies.
resetAssistantMessageState(assistantTexts.length); resetAssistantMessageState(assistantTexts.length);
// Use assistant message_start as the earliest "writing" signal for typing.
void params.onAssistantMessageStart?.();
} }
} }

View File

@@ -51,6 +51,7 @@ type EmbeddedPiAgentParams = {
text?: string; text?: string;
mediaUrls?: string[]; mediaUrls?: string[];
}) => Promise<void> | void; }) => Promise<void> | void;
onAssistantMessageStart?: () => Promise<void> | void;
onBlockReply?: (payload: { onBlockReply?: (payload: {
text?: string; text?: string;
mediaUrls?: string[]; mediaUrls?: string[];
@@ -212,19 +213,21 @@ describe("runReplyAgent typing (heartbeat)", () => {
expect(typing.startTypingLoop).not.toHaveBeenCalled(); expect(typing.startTypingLoop).not.toHaveBeenCalled();
}); });
it("starts typing only on deltas in message mode", async () => { it("starts typing on assistant message start in message mode", async () => {
runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ runEmbeddedPiAgentMock.mockImplementationOnce(
payloads: [{ text: "final" }], async (params: EmbeddedPiAgentParams) => {
meta: {}, await params.onAssistantMessageStart?.();
})); return { payloads: [{ text: "final" }], meta: {} };
},
);
const { run, typing } = createMinimalRun({ const { run, typing } = createMinimalRun({
typingMode: "message", typingMode: "message",
}); });
await run(); await run();
expect(typing.startTypingLoop).toHaveBeenCalled();
expect(typing.startTypingOnText).not.toHaveBeenCalled(); expect(typing.startTypingOnText).not.toHaveBeenCalled();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
}); });
it("starts typing from reasoning stream in thinking mode", async () => { it("starts typing from reasoning stream in thinking mode", async () => {

View File

@@ -689,6 +689,9 @@ export async function runReplyAgent(params: {
}); });
} }
: undefined, : undefined,
onAssistantMessageStart: async () => {
await typingSignals.signalMessageStart();
},
onReasoningStream: onReasoningStream:
typingSignals.shouldStartOnReasoning || opts?.onReasoningStream typingSignals.shouldStartOnReasoning || opts?.onReasoningStream
? async (payload) => { ? async (payload) => {

View File

@@ -96,6 +96,20 @@ describe("createTypingSignaler", () => {
expect(typing.startTypingLoop).not.toHaveBeenCalled(); expect(typing.startTypingLoop).not.toHaveBeenCalled();
}); });
it("signals on message start for message mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalMessageStart();
expect(typing.startTypingLoop).toHaveBeenCalled();
expect(typing.startTypingOnText).not.toHaveBeenCalled();
});
it("signals on reasoning for thinking mode", async () => { it("signals on reasoning for thinking mode", async () => {
const typing = createMockTypingController(); const typing = createMockTypingController();
const signaler = createTypingSignaler({ const signaler = createTypingSignaler({

View File

@@ -25,9 +25,11 @@ export function resolveTypingMode({
export type TypingSignaler = { export type TypingSignaler = {
mode: TypingMode; mode: TypingMode;
shouldStartImmediately: boolean; shouldStartImmediately: boolean;
shouldStartOnMessageStart: boolean;
shouldStartOnText: boolean; shouldStartOnText: boolean;
shouldStartOnReasoning: boolean; shouldStartOnReasoning: boolean;
signalRunStart: () => Promise<void>; signalRunStart: () => Promise<void>;
signalMessageStart: () => Promise<void>;
signalTextDelta: (text?: string) => Promise<void>; signalTextDelta: (text?: string) => Promise<void>;
signalReasoningDelta: () => Promise<void>; signalReasoningDelta: () => Promise<void>;
signalToolStart: () => Promise<void>; signalToolStart: () => Promise<void>;
@@ -40,6 +42,7 @@ export function createTypingSignaler(params: {
}): TypingSignaler { }): TypingSignaler {
const { typing, mode, isHeartbeat } = params; const { typing, mode, isHeartbeat } = params;
const shouldStartImmediately = mode === "instant"; const shouldStartImmediately = mode === "instant";
const shouldStartOnMessageStart = mode === "message";
const shouldStartOnText = mode === "message" || mode === "instant"; const shouldStartOnText = mode === "message" || mode === "instant";
const shouldStartOnReasoning = mode === "thinking"; const shouldStartOnReasoning = mode === "thinking";
const disabled = isHeartbeat || mode === "never"; const disabled = isHeartbeat || mode === "never";
@@ -49,6 +52,11 @@ export function createTypingSignaler(params: {
await typing.startTypingLoop(); await typing.startTypingLoop();
}; };
const signalMessageStart = async () => {
if (disabled || !shouldStartOnMessageStart) return;
await typing.startTypingLoop();
};
const signalTextDelta = async (text?: string) => { const signalTextDelta = async (text?: string) => {
if (disabled) return; if (disabled) return;
if (shouldStartOnText) { if (shouldStartOnText) {
@@ -80,9 +88,11 @@ export function createTypingSignaler(params: {
return { return {
mode, mode,
shouldStartImmediately, shouldStartImmediately,
shouldStartOnMessageStart,
shouldStartOnText, shouldStartOnText,
shouldStartOnReasoning, shouldStartOnReasoning,
signalRunStart, signalRunStart,
signalMessageStart,
signalTextDelta, signalTextDelta,
signalReasoningDelta, signalReasoningDelta,
signalToolStart, signalToolStart,