fix(sessions): resync session status on SSE reconnect to clear stuck 'processing' after network drop (closes #123) (#124)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
57
src/lib/session-status-reconcile.test.ts
Normal file
57
src/lib/session-status-reconcile.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { isSessionActuallyIdle } from "./session-status-reconcile.ts"
|
||||
import type { Message } from "./sdk.ts"
|
||||
|
||||
const userMsg = (id: string, overrides: Partial<Message> = {}): Message => ({
|
||||
id,
|
||||
sessionID: "s1",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const assistantMsg = (id: string, overrides: Partial<Message> = {}): Message => ({
|
||||
id,
|
||||
sessionID: "s1",
|
||||
role: "assistant",
|
||||
time: { created: 1 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
test("no messages -> still busy (nothing to reconcile from)", () => {
|
||||
assert.equal(isSessionActuallyIdle(undefined), false)
|
||||
assert.equal(isSessionActuallyIdle(null), false)
|
||||
assert.equal(isSessionActuallyIdle([]), false)
|
||||
})
|
||||
|
||||
test("last message is a completed assistant reply -> idle (the missed-event case)", () => {
|
||||
const messages = [userMsg("u1"), assistantMsg("a1", { time: { created: 1, completed: 5 } })]
|
||||
assert.equal(isSessionActuallyIdle(messages), true)
|
||||
})
|
||||
|
||||
test("last message is an assistant reply that errored out -> idle", () => {
|
||||
const messages = [userMsg("u1"), assistantMsg("a1", { error: { message: "boom" } })]
|
||||
assert.equal(isSessionActuallyIdle(messages), true)
|
||||
})
|
||||
|
||||
test("last message is a user prompt awaiting a reply -> still busy", () => {
|
||||
const messages = [assistantMsg("a1", { time: { created: 1, completed: 5 } }), userMsg("u2")]
|
||||
assert.equal(isSessionActuallyIdle(messages), false)
|
||||
})
|
||||
|
||||
test("last message is an assistant reply still streaming (no completed, no error) -> still busy", () => {
|
||||
const messages = [userMsg("u1"), assistantMsg("a1")]
|
||||
assert.equal(isSessionActuallyIdle(messages), false)
|
||||
})
|
||||
|
||||
test("a follow-up user prompt after a completed assistant reply -> still busy again", () => {
|
||||
// Server queued a second turn: the previous reply completed, but a new user
|
||||
// message was appended after it, so the run may be in progress again.
|
||||
const messages = [
|
||||
userMsg("u1"),
|
||||
assistantMsg("a1", { time: { created: 1, completed: 5 } }),
|
||||
userMsg("u2"),
|
||||
]
|
||||
assert.equal(isSessionActuallyIdle(messages), false)
|
||||
})
|
||||
31
src/lib/session-status-reconcile.ts
Normal file
31
src/lib/session-status-reconcile.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Message } from "./sdk"
|
||||
|
||||
/**
|
||||
* Decide whether a session the client believes is "busy" has actually
|
||||
* finished, based on the tail of its message history.
|
||||
*
|
||||
* Why this exists (issue #123): `sessionStatus`/`sending` are SSE-driven —
|
||||
* the server's busy -> idle `session.status` event is the only thing that
|
||||
* normally clears them. If the network drops while a session is busy and
|
||||
* that busy -> idle event fires DURING the outage, it is lost: SSE reconnect
|
||||
* resumes the stream from "now", it does not replay missed events. Without a
|
||||
* resync, the UI shows a stuck 'processing' spinner forever even though the
|
||||
* server finished long ago.
|
||||
*
|
||||
* Heuristic: idle iff the most recent message is an assistant message that
|
||||
* has terminated — either it completed normally (`time.completed` set) or it
|
||||
* ended in error (`error` set; the server still finalizes the message on
|
||||
* error, it just never gets a successful `time.completed`). Anything else —
|
||||
* the last message is a user prompt still awaiting a reply, or an assistant
|
||||
* message that hasn't finished streaming — means the run may still be in
|
||||
* progress server-side. Callers MUST treat that as "still busy" and leave the
|
||||
* local state alone: this heuristic only ever clears a stale busy flag, it
|
||||
* never forces a session busy that the server hasn't reported as such, so a
|
||||
* genuinely still-busy session is never clobbered.
|
||||
*/
|
||||
export function isSessionActuallyIdle(messages: Message[] | null | undefined): boolean {
|
||||
if (!messages || messages.length === 0) return false
|
||||
const last = messages[messages.length - 1]
|
||||
if (last.role !== "assistant") return false
|
||||
return Boolean(last.time?.completed) || Boolean(last.error)
|
||||
}
|
||||
Reference in New Issue
Block a user