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

A lost or delayed turn-ending `session.idle` left the busy spinner up until
the watchdog poll caught it (5-10s). An assistant `message.updated` carrying
`time.completed` now schedules one status check for that session, and
`streaming.ts` stops treating a completed trailing message as streaming.

The check is deferred by 750ms and re-reads the session status when the timer
fires, so the overwhelmingly common case — the turn's own `session.idle`
arriving right behind the completed message — settles on its own and costs
zero extra requests; only a session the store still believes busy spends a
fetch. The poll shares the watchdog's in-flight directory guard, so the
deferred check and the periodic poll cannot overlap on one directory.
Status authority is unchanged: the monotonic pass never lowers status, and an
authoritative resync runs only when the snapshot disagrees.
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 00:29:42 +03:00
parent 5c26c6ae21
commit 10634c4512
5 changed files with 100 additions and 86 deletions
+7 -5
View File
@@ -16,13 +16,15 @@ import {
const message = (id: string, role: "user" | "assistant"): Message => ({
id,
role,
time: { created: 1 },
} as unknown as Message)
const completedAssistantMessage = (id: string): Message => ({
id,
role: "assistant",
time: { created: 1, completed: 100 },
} as unknown as Message)
const completedAssistantMessage = (id: string): Message => {
const base = message(id, "assistant")
// SAFETY: test fixture — the streaming reducers read only `id`, `role`, and
// `time.completed`, which this literal provides.
return { ...base, time: { created: 1, completed: 100 } } as Message
}
const stateWithMessages = (messages: Message[], status: SessionStatus = { type: "busy" } as SessionStatus): State => ({
...INITIAL_STATE,