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:
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user