fix(chat): add delivery watchdog for stranded quick chat messages
Follow-up to the quick chat send fix. The send-time stale-stream recovery only fires when a message is queued while the streaming flag is already stuck, and the pre-session flush effect fires once on session activation and bails permanently if a stream ref is lingering. Either gap leaves a queued message stranded idle in the composer — shown locally but never sent to the agent or persisted (so also absent from regular chat). Add a delivery watchdog: whenever a message stays pending under an active session, re-confirm after a short delay and force-deliver it if the server reports no generation in flight and no live stream is connected. This is a catch-all backstop independent of which targeted flush trigger bailed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
"@fusion/dashboard": patch
|
||||
---
|
||||
|
||||
Fix two mobile chat send failures. The regular chat send button was dead to touch because the action only ran on `onClick`, which iOS suppresses after `preventDefault()` in the touch sequence — it now fires from pointerdown/touchstart with a dedupe latch. Quick chat messages could strand in the composer (shown locally but never sent to the agent or persisted) when a dropped stream left the streaming flag stuck `true`; a queued send now detects the stale flag via the stream's connection state and the server's generation status, then recovers and flushes.
|
||||
Fix two mobile chat send failures. The regular chat send button was dead to touch because the action only ran on `onClick`, which iOS suppresses after `preventDefault()` in the touch sequence — it now fires from pointerdown/touchstart with a dedupe latch. Quick chat messages could strand in the composer (shown locally but never sent to the agent or persisted) when a queued message's delivery trigger bailed — a dropped stream leaving the streaming flag stuck `true`, or a stream that looked healthy when queued but then stalled. A queued send now detects a stale flag at send time via the stream's connection state and the server's generation status, and a delivery watchdog re-confirms any message that stays pending and force-delivers it once no generation is actually in flight.
|
||||
|
||||
@@ -195,6 +195,54 @@ describe("useQuickChat", () => {
|
||||
expect(result.current.pendingMessage).toBe("");
|
||||
});
|
||||
|
||||
it("delivers a queued message via the watchdog when a stream stalls after the send was queued", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
// Server confirms nothing is generating: the stream died after we queued.
|
||||
mockFetchChatSession.mockResolvedValue({
|
||||
session: { ...session, isGenerating: false },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useQuickChat("proj-123"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchSession("agent-001");
|
||||
});
|
||||
|
||||
// First send attaches a stream that is connected at send time but never
|
||||
// completes (onDone/onError never fire).
|
||||
let connected = true;
|
||||
mockStreamChatResponse.mockReturnValue({ close: vi.fn(), isConnected: () => connected });
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("First");
|
||||
});
|
||||
|
||||
// Second send while streaming: queued. The send-time recovery sees the
|
||||
// stream still connected, so it correctly leaves the message queued to be
|
||||
// flushed by the stream's onDone — which never arrives.
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("Second");
|
||||
});
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The stream goes dead. The watchdog re-confirms after its delay and, since
|
||||
// the server reports no generation in flight, delivers the queued message.
|
||||
connected = false;
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
|
||||
expect(mockStreamChatResponse).toHaveBeenCalledTimes(2);
|
||||
expect(mockStreamChatResponse.mock.calls[1]?.[1]).toBe("Second");
|
||||
expect(result.current.pendingMessage).toBe("");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("sendMessage returns a promise that resolves on stream completion", async () => {
|
||||
const session = makeSession({ id: "session-001", agentId: "agent-001" });
|
||||
mockFetchResumeChatSession.mockResolvedValue({ session });
|
||||
|
||||
@@ -190,6 +190,12 @@ function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
|
||||
};
|
||||
}
|
||||
|
||||
// Backstop delay before a still-pending queued message is re-confirmed and
|
||||
// force-delivered. Long enough to let the targeted flush paths (pre-session
|
||||
// activation, stream completion) deliver first; short enough that a stranded
|
||||
// message reaches the agent quickly.
|
||||
const QUEUED_MESSAGE_DELIVERY_WATCHDOG_MS = 1500;
|
||||
|
||||
/**
|
||||
* Hook for the QuickChatFAB component.
|
||||
* Provides chat session management and SSE streaming for real-time AI responses.
|
||||
@@ -1116,6 +1122,56 @@ export function useQuickChat(
|
||||
flushPendingMessage();
|
||||
}, [activeSession, flushPendingMessage]);
|
||||
|
||||
// Delivery backstop for queued messages. The targeted flush triggers
|
||||
// (pre-session activation, stream onDone/onError, send-time stale recovery)
|
||||
// each fire once on a specific transition. If the relevant one bails — a
|
||||
// lingering stream ref at session activation, or a stream that looked healthy
|
||||
// when we chose to wait for its onDone but then died without firing it — the
|
||||
// queued message strands in the composer forever: shown locally but never
|
||||
// sent to the agent or persisted (so also absent from regular chat). Whenever
|
||||
// a message stays pending under an active session, re-confirm after a short
|
||||
// delay and deliver it if no generation is actually in flight server-side.
|
||||
useEffect(() => {
|
||||
const sessionId = activeSession?.id;
|
||||
if (!sessionId || pendingMessage.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
if (cancelled || pendingMessageRef.current.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
void fetchChatSession(sessionId, projectId)
|
||||
.then(({ session: refreshed }) => {
|
||||
if (
|
||||
cancelled ||
|
||||
// A real generation is in flight: its stream will flush the queue.
|
||||
refreshed.isGenerating ||
|
||||
// A live stream reconnected while we waited: defer to it.
|
||||
streamRef.current?.isConnected() ||
|
||||
activeSessionRef.current?.id !== sessionId ||
|
||||
pendingMessageRef.current.trim().length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.close();
|
||||
streamRef.current = null;
|
||||
}
|
||||
setIsStreaming(false);
|
||||
isStreamingRef.current = false;
|
||||
flushPendingMessage();
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the queued message; a later trigger can still deliver it.
|
||||
});
|
||||
}, QUEUED_MESSAGE_DELIVERY_WATCHDOG_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [activeSession?.id, pendingMessage, projectId, flushPendingMessage]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user