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
@@ -0,0 +1,149 @@
/**
* Tests for the immediate status poll fired when an assistant message
* completes (issue OPE-193, B1): the busy spinner must not linger for up to a
* full watchdog poll interval after a turn completed when the session.idle
* event was delayed or lost.
*/
import { beforeEach, describe, expect, mock, test } from "bun:test"
import { create, type StoreApi } from "zustand"
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
import { INITIAL_STATE } from "../types"
import type { DirectoryStore } from "../child-store"
type StatusSnapshot = Record<string, SessionStatus | undefined>
let statusSnapshotResult: StatusSnapshot | null = { ses_1: { type: "idle" } }
let statusSnapshotErrors = 0
const statusSnapshotCalls: string[] = []
mock.module("@/lib/opencode/client", () => ({
opencodeClient: {
getSessionStatusForDirectory: mock((directory: string) => {
statusSnapshotCalls.push(directory)
if (statusSnapshotErrors > 0) {
statusSnapshotErrors -= 1
return Promise.resolve(null)
}
return Promise.resolve(statusSnapshotResult)
}),
},
}))
mock.module("@/lib/runtime-switch", () => ({
getRuntimeKey: () => "test-runtime",
}))
import { maybePollStatusAfterMessageCompletion } from "../sync-context"
const createStore = (status: SessionStatus | undefined): StoreApi<DirectoryStore> => {
return create<DirectoryStore>()((set) => ({
...INITIAL_STATE,
...(status ? { session_status: { ses_1: status } } : {}),
patch: (partial) => set(partial),
replace: (next) => set(next),
}))
}
const waitForPollSettled = async (): Promise<void> => {
// The helper runs under the background-network concurrency gate; give the
// task chain (and any promise-based snapshot) real time to finish.
await new Promise((resolve) => setTimeout(resolve, 25))
await new Promise((resolve) => setTimeout(resolve, 25))
}
describe("maybePollStatusAfterMessageCompletion (issue OPE-193)", () => {
beforeEach(() => {
statusSnapshotResult = { ses_1: { type: "idle" } }
statusSnapshotErrors = 0
statusSnapshotCalls.length = 0
})
test("does not poll when the store believes the session is already idle", async () => {
const store = createStore({ type: "idle" })
maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1")
await waitForPollSettled()
expect(statusSnapshotCalls).toEqual([])
expect(store.getState().session_status?.ses_1?.type).toBe("idle")
})
test("does not poll without a directory or session id", async () => {
const store = createStore({ type: "busy" })
maybePollStatusAfterMessageCompletion("", store, "ses_1")
maybePollStatusAfterMessageCompletion("global", store, "ses_1")
maybePollStatusAfterMessageCompletion("/test/project", store, "")
await waitForPollSettled()
expect(statusSnapshotCalls).toEqual([])
})
test("settles a busy session to idle immediately when the snapshot omits it", async () => {
const store = createStore({ type: "busy" })
maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1")
await waitForPollSettled()
expect(statusSnapshotCalls).toEqual(["/test/project", "/test/project"])
expect(store.getState().session_status?.ses_1?.type).toBe("idle")
})
test("keeps the session busy when the snapshot confirms it is still active", async () => {
const store = createStore({ type: "busy" })
statusSnapshotResult = { ses_1: { type: "busy" } }
maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1")
await waitForPollSettled()
// Monotonic poll confirms busy; the snapshot is not idle, so no
// authoritative escalation runs.
expect(statusSnapshotCalls).toEqual(["/test/project"])
expect(store.getState().session_status?.ses_1?.type).toBe("busy")
})
test("preserves the busy status when the status fetch fails", async () => {
const store = createStore({ type: "busy" })
statusSnapshotErrors = 1
maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1")
await waitForPollSettled()
expect(statusSnapshotCalls).toEqual(["/test/project"])
// Failure is not treated as authoritative empty: the busy status stays
// until the watchdog poll (or a live event) corrects it.
expect(store.getState().session_status?.ses_1?.type).toBe("busy")
})
test("deduplicates concurrent polls for the same directory", async () => {
const store = createStore({ type: "busy" })
statusSnapshotResult = new Promise((resolve) => {
setTimeout(() => resolve({ ses_1: { type: "idle" } }), 10)
}) as unknown as StatusSnapshot
maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1")
maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1")
maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1")
await waitForPollSettled()
expect(statusSnapshotCalls).toEqual(["/test/project", "/test/project"])
})
test("does not poll again while a previous poll for the directory is in flight", async () => {
const store = createStore({ type: "busy" })
let release: () => void = () => {}
statusSnapshotResult = new Promise((resolve) => {
release = () => resolve({ ses_1: { type: "idle" } })
}) as unknown as StatusSnapshot
maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1")
maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1")
release()
await waitForPollSettled()
// First call ran the poll; the second call was deduped by the in-flight
// guard. The escalation (second fetch) is the authoritative resync.
expect(statusSnapshotCalls).toEqual(["/test/project", "/test/project"])
expect(store.getState().session_status?.ses_1?.type).toBe("idle")
})
})