fix(sync): settle completed turns and finished messages promptly

Two remaining stuck/incorrect busy-state edge cases from the post-#483
spinner audit (OPE-193):

- B1: when a turn ended but the session.idle SSE event was delayed or
  lost, the busy spinner kept showing until the next watchdog poll tick
  (~5s) and its escalation (~10s). An assistant message.updated that
  carries time.completed now triggers one immediate directory status
  poll (monotonic confirm, authoritative settle when the snapshot
  reports the session idle) — recovery drops to a single round-trip,
  with one in-flight fetch per directory and the watchdog poll as the
  backstop.

- C1: the streaming derivation marked the trailing assistant message as
  streaming while the session stayed busy even after the server stamped
  time.completed (whole response incl. tools finished) — the typing
  indicator and streaming part-update suspension lingered on finished
  content until the session settled or the next message started. A
  completed trailing message is now never marked streaming; both the
  full and incremental derivations complete the previous streaming
  message instead.

Refs OPE-193
This commit is contained in:
Serhii Dziupin
2026-08-05 11:57:12 +03:00
parent 34c221b07f
commit 498a029e51
5 changed files with 313 additions and 0 deletions
+74
View File
@@ -18,6 +18,12 @@ const message = (id: string, role: "user" | "assistant"): Message => ({
role,
} as unknown as Message)
const completedAssistantMessage = (id: string): Message => ({
id,
role: "assistant",
time: { created: 1, completed: 100 },
} as unknown as Message)
const stateWithMessages = (messages: Message[], status: SessionStatus = { type: "busy" } as SessionStatus): State => ({
...INITIAL_STATE,
session_status: {
@@ -163,4 +169,72 @@ describe("updateStreamingState", () => {
expect(streaming.messageStreamStates.get("msg_assistant_1")?.phase).toBe("completed")
expect(streaming.messageStreamStates.get("msg_assistant_2")?.phase).toBe("streaming")
})
test("completes a streaming message when the trailing assistant message finishes while the session stays busy", () => {
updateStreamingState(stateWithMessages([
message("msg_user_1", "user"),
message("msg_assistant_1", "assistant"),
]))
expect(useStreamingStore.getState().streamingMessageIds.get("ses_1")).toBe("msg_assistant_1")
// The message completed (time.completed) but the turn keeps running
// (next step / tool phase) — the finished message must not stay marked
// as streaming with the typing indicator and part-update suspension on it.
updateStreamingState(stateWithMessages([
message("msg_user_1", "user"),
completedAssistantMessage("msg_assistant_1"),
]))
const streaming = useStreamingStore.getState()
expect(streaming.streamingMessageIds.get("ses_1")).toBeNull()
expect(streaming.messageStreamStates.get("msg_assistant_1")?.phase).toBe("completed")
})
test("does not mark an already-completed trailing assistant message as streaming", () => {
updateStreamingState(stateWithMessages([
message("msg_user_1", "user"),
completedAssistantMessage("msg_assistant_1"),
]))
const streaming = useStreamingStore.getState()
expect(streaming.streamingMessageIds.get("ses_1") ?? null).toBeNull()
expect(streaming.messageStreamStates.has("msg_assistant_1")).toBe(false)
})
test("incrementally clears the streaming marker when the trailing message completes while busy", () => {
const previous = stateWithMessages([
message("msg_user_1", "user"),
message("msg_assistant_1", "assistant"),
])
updateStreamingState(previous, 10)
expect(useStreamingStore.getState().streamingMessageIds.get("ses_1")).toBe("msg_assistant_1")
const next = stateWithMessages([
message("msg_user_1", "user"),
completedAssistantMessage("msg_assistant_1"),
])
updateChangedStreamingSessions(next, previous, 20)
const streaming = useStreamingStore.getState()
expect(streaming.streamingMessageIds.get("ses_1")).toBeNull()
expect(streaming.messageStreamStates.get("msg_assistant_1")?.phase).toBe("completed")
})
test("keeps the next assistant message streaming after an intermediate message completed while busy", () => {
const previous = stateWithMessages([
message("msg_user_1", "user"),
completedAssistantMessage("msg_assistant_1"),
])
updateStreamingState(previous, 10)
expect(useStreamingStore.getState().streamingMessageIds.get("ses_1") ?? null).toBeNull()
const next = stateWithMessages([
message("msg_user_1", "user"),
completedAssistantMessage("msg_assistant_1"),
message("msg_assistant_2", "assistant"),
])
updateChangedStreamingSessions(next, previous, 20)
expect(useStreamingStore.getState().streamingMessageIds.get("ses_1")).toBe("msg_assistant_2")
})
})