(undefined)
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
@@ -336,13 +346,215 @@ export const ExtensionStateContextProvider: React.FC<{
}))
}, [])
+ const clearClineMessagesResync = useCallback(
+ () => {
+ resyncPendingRef.current = false
+ if (resyncTimeoutRef.current !== undefined) {
+ window.clearTimeout(resyncTimeoutRef.current)
+ resyncTimeoutRef.current = undefined
+ }
+ },
+ // Stryker disable next-line ArrayDeclaration: an inserted constant never changes, so this ref-only callback retains the same identity and captures.
+ [],
+ )
+
+ const clearClineMessagesSnapshot = useCallback(
+ () => {
+ activeSnapshotRef.current = null
+ if (snapshotTimeoutRef.current !== undefined) {
+ window.clearTimeout(snapshotTimeoutRef.current)
+ snapshotTimeoutRef.current = undefined
+ }
+ },
+ // Stryker disable next-line ArrayDeclaration: an inserted constant cannot change this ref-only callback's stable identity or captured values.
+ [],
+ )
+
+ const requestClineMessagesResync = useCallback(
+ (receivedSeq?: number) => {
+ if (resyncPendingRef.current) {
+ return
+ }
+ resyncPendingRef.current = true
+ resyncTimeoutRef.current = window.setTimeout(() => {
+ resyncPendingRef.current = false
+ resyncTimeoutRef.current = undefined
+ }, CLINE_MESSAGES_RESYNC_TIMEOUT_MS)
+ vscode.postMessage({
+ type: "requestClineMessagesResync",
+ taskId: activeTaskIdRef.current,
+ expectedSeq: clineMessagesSeqRef.current + 1,
+ receivedSeq,
+ })
+ },
+ // Stryker disable next-line ArrayDeclaration: an inserted constant never changes, so this ref-only callback retains the same identity and captures.
+ [],
+ )
+
+ const retryClineMessagesResync = useCallback(
+ (receivedSeq?: number) => {
+ clearClineMessagesResync()
+ requestClineMessagesResync(receivedSeq)
+ },
+ // Stryker disable next-line ArrayDeclaration: both dependencies are stable callbacks; omitting them cannot alter callback identity or captured values.
+ [clearClineMessagesResync, requestClineMessagesResync],
+ )
+
+ const startClineMessagesSnapshotTimeout = useCallback(
+ (snapshotId: string, seq: number) => {
+ if (snapshotTimeoutRef.current !== undefined) {
+ window.clearTimeout(snapshotTimeoutRef.current)
+ }
+ snapshotTimeoutRef.current = window.setTimeout(() => {
+ const snapshot = activeSnapshotRef.current
+ if (snapshot?.snapshotId !== snapshotId || snapshot.seq !== seq) {
+ return
+ }
+ activeSnapshotRef.current = null
+ snapshotTimeoutRef.current = undefined
+ retryClineMessagesResync(seq)
+ }, CLINE_MESSAGES_SNAPSHOT_TIMEOUT_MS)
+ },
+ // Stryker disable next-line ArrayDeclaration: retryClineMessagesResync is stable, so omitting it cannot alter callback identity or captured values.
+ [retryClineMessagesResync],
+ )
+
+ const applyClineMessagesDelta = useCallback(
+ (message: ExtensionMessage, operation: "append" | "update") => {
+ const seq = message.clineMessagesSeq as number
+ const clineMessage = message.clineMessage
+ if (
+ activeTaskIdRef.current === undefined ||
+ message.taskId !== activeTaskIdRef.current ||
+ message.taskInstanceId !== activeTaskInstanceIdRef.current
+ ) {
+ return
+ }
+ if (!Number.isSafeInteger(seq) || seq < 0 || !clineMessage) {
+ requestClineMessagesResync(typeof seq === "number" ? seq : undefined)
+ return
+ }
+
+ const snapshot = activeSnapshotRef.current
+ if (snapshot) {
+ // The snapshot already includes all deltas through its sequence. A newer
+ // delta interleaved with it means the stream cannot be applied atomically.
+ if (seq <= snapshot.seq) {
+ return
+ }
+ clearClineMessagesSnapshot()
+ retryClineMessagesResync(seq)
+ return
+ }
+ if (seq <= clineMessagesSeqRef.current) {
+ return
+ }
+ if (seq !== clineMessagesSeqRef.current + 1) {
+ requestClineMessagesResync(seq)
+ return
+ }
+
+ let nextMessages: ClineMessage[]
+ if (operation === "append") {
+ nextMessages = [...clineMessagesRef.current, clineMessage]
+ clineMessagesIndex.set(clineMessage.ts, nextMessages.length - 1)
+ } else {
+ const index = clineMessagesIndex.get(clineMessage.ts)
+ if (index === undefined) {
+ requestClineMessagesResync(seq)
+ return
+ }
+ // Timestamp lookup is O(1) on average; the immutable array copy is still O(N).
+ nextMessages = [...clineMessagesRef.current]
+ nextMessages[index] = clineMessage
+ }
+
+ clineMessagesRef.current = nextMessages
+ clineMessagesSeqRef.current = seq
+ setState((prevState) => ({
+ ...prevState,
+ clineMessages: nextMessages,
+ clineMessagesSeq: seq,
+ }))
+ },
+ // Stryker disable next-line ArrayDeclaration: the index and callbacks are stable; an empty dependency list produces the same closure for the provider lifetime.
+ [clearClineMessagesSnapshot, clineMessagesIndex, requestClineMessagesResync, retryClineMessagesResync],
+ )
+
const handleMessage = useCallback(
(event: MessageEvent) => {
+ const replaceClineMessages = (messages: ClineMessage[]) => {
+ clineMessagesRef.current = messages
+ clineMessagesIndex.clear()
+ messages.forEach((message, index) => clineMessagesIndex.set(message.ts, index))
+ }
const message: ExtensionMessage = event.data
switch (message.type) {
+ case "clineMessagesFocus":
case "state": {
- const newState = message.state ?? {}
- setState((prevState) => mergeExtensionState(prevState, newState))
+ const {
+ clineMessages: _ignoredMessages,
+ clineMessagesSeq: _ignoredMessagesSeq,
+ ...newState
+ } = message.type === "clineMessagesFocus"
+ ? {
+ currentTaskId: message.taskId ?? null,
+ currentTaskInstanceId: message.taskInstanceId ?? null,
+ }
+ : (message.state ?? {})
+ const hasCurrentTaskId = Object.prototype.hasOwnProperty.call(newState, "currentTaskId")
+ const nextTaskId = hasCurrentTaskId
+ ? (newState.currentTaskId ?? undefined)
+ : activeTaskIdRef.current
+ const taskChanged = hasCurrentTaskId && nextTaskId !== activeTaskIdRef.current
+ const taskCleared = hasCurrentTaskId && newState.currentTaskId === null
+ const nextTaskInstanceId = taskCleared
+ ? undefined
+ : newState.currentTaskInstanceId !== undefined
+ ? (newState.currentTaskInstanceId ?? undefined)
+ : taskChanged
+ ? undefined
+ : activeTaskInstanceIdRef.current
+ const focusChanged = taskChanged || nextTaskInstanceId !== activeTaskInstanceIdRef.current
+ if (focusChanged || taskCleared) {
+ // Update both refs before React renders so queued frames cannot use the old scope.
+ activeTaskIdRef.current = nextTaskId
+ activeTaskInstanceIdRef.current = nextTaskInstanceId
+ clineMessagesSeqRef.current = 0
+ replaceClineMessages([])
+ clearClineMessagesSnapshot()
+ clearClineMessagesResync()
+ }
+ setState((prevState) => {
+ const merged = mergeExtensionState(prevState, {
+ ...newState,
+ currentTaskInstanceId:
+ newState.currentTaskInstanceId !== undefined
+ ? newState.currentTaskInstanceId
+ : taskChanged
+ ? undefined
+ : prevState.currentTaskInstanceId,
+ })
+ if (taskCleared) {
+ return {
+ ...merged,
+ currentTaskId: null,
+ currentTaskInstanceId: null,
+ currentTaskItem: undefined,
+ currentTaskTodos: [],
+ messageQueue: [],
+ clineMessages: [],
+ clineMessagesSeq: 0,
+ }
+ }
+ return focusChanged ? { ...merged, clineMessages: [], clineMessagesSeq: 0 } : merged
+ })
+ if (taskCleared) {
+ setCurrentCheckpoint(undefined)
+ }
+ // Early scope publication is not settings hydration. In particular, it must
+ // not reopen setup and unmount the chat while generic metadata is pending.
+ if (message.type === "clineMessagesFocus") break
setShowWelcome(!checkExistKey(newState.apiConfiguration, newState.zooCodeIsAuthenticated))
setDidHydrateState(true)
// Update alwaysAllowFollowupQuestions if present in state message
@@ -404,26 +616,155 @@ export const ExtensionStateContextProvider: React.FC<{
setCommands(message.commands ?? [])
break
}
- case "messageUpdated": {
- const clineMessage = message.clineMessage!
- setState((prevState) => {
- // worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock
- const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === clineMessage.ts)
- if (lastIndex !== -1) {
- const newClineMessages = [...prevState.clineMessages]
- newClineMessages[lastIndex] = clineMessage
- return { ...prevState, clineMessages: newClineMessages }
+ case "clineMessagesSnapshotStart": {
+ if (
+ activeTaskIdRef.current === undefined ||
+ message.taskId !== activeTaskIdRef.current ||
+ message.taskInstanceId !== activeTaskInstanceIdRef.current
+ ) {
+ break
+ }
+
+ const seq = message.clineMessagesSeq as number
+ if (!Number.isSafeInteger(seq) || seq < 0) {
+ clearClineMessagesSnapshot()
+ retryClineMessagesResync(typeof seq === "number" ? seq : undefined)
+ break
+ }
+ if (seq < clineMessagesSeqRef.current) {
+ break
+ }
+
+ const total = message.snapshotTotal as number
+ if (!message.snapshotId || !Number.isSafeInteger(total) || total < 0) {
+ clearClineMessagesSnapshot()
+ retryClineMessagesResync(seq)
+ break
+ }
+
+ const activeSnapshot = activeSnapshotRef.current
+ if (activeSnapshot?.snapshotId === message.snapshotId && activeSnapshot.seq === seq) {
+ break
+ }
+ if (activeSnapshot && seq < activeSnapshot.seq) {
+ break
+ }
+
+ activeSnapshotRef.current = {
+ snapshotId: message.snapshotId,
+ taskId: message.taskId,
+ seq,
+ total,
+ messages: [],
+ }
+ startClineMessagesSnapshotTimeout(message.snapshotId, seq)
+ break
+ }
+ case "clineMessagesSnapshotChunk": {
+ if (
+ activeTaskIdRef.current === undefined ||
+ message.taskId !== activeTaskIdRef.current ||
+ message.taskInstanceId !== activeTaskInstanceIdRef.current
+ ) {
+ break
+ }
+
+ const seq = message.clineMessagesSeq as number
+ const snapshot = activeSnapshotRef.current
+ if (!Number.isSafeInteger(seq) || seq < 0) {
+ clearClineMessagesSnapshot()
+ retryClineMessagesResync(typeof seq === "number" ? seq : undefined)
+ break
+ }
+ if (!snapshot) {
+ if (seq > clineMessagesSeqRef.current) {
+ retryClineMessagesResync(seq)
}
- // Log a warning if messageUpdated arrives for a timestamp not in the
- // frontend's clineMessages. With the seq guard and cloud event isolation
- // (layers 1+2), this should not happen under normal conditions. If it
- // does, it signals a state synchronization issue worth investigating.
- console.warn(
- `[messageUpdated] Received update for unknown message ts=${clineMessage.ts}, dropping. ` +
- `Frontend has ${prevState.clineMessages.length} messages.`,
- )
- return prevState
- })
+ break
+ }
+ if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) {
+ if (seq > snapshot.seq) {
+ clearClineMessagesSnapshot()
+ retryClineMessagesResync(seq)
+ }
+ break
+ }
+
+ const chunk = message.clineMessages
+ const startIndex = message.snapshotStartIndex as number
+ if (
+ !Array.isArray(chunk) ||
+ chunk.length === 0 ||
+ !Number.isSafeInteger(startIndex) ||
+ startIndex !== snapshot.messages.length ||
+ snapshot.messages.length + chunk.length > snapshot.total
+ ) {
+ clearClineMessagesSnapshot()
+ retryClineMessagesResync(seq)
+ break
+ }
+
+ snapshot.messages.push(...chunk)
+ break
+ }
+ case "clineMessagesSnapshotEnd": {
+ if (
+ activeTaskIdRef.current === undefined ||
+ message.taskId !== activeTaskIdRef.current ||
+ message.taskInstanceId !== activeTaskInstanceIdRef.current
+ ) {
+ break
+ }
+
+ const seq = message.clineMessagesSeq as number
+ const snapshot = activeSnapshotRef.current
+ if (!Number.isSafeInteger(seq) || seq < 0) {
+ clearClineMessagesSnapshot()
+ retryClineMessagesResync(typeof seq === "number" ? seq : undefined)
+ break
+ }
+ if (!snapshot) {
+ if (seq > clineMessagesSeqRef.current) {
+ retryClineMessagesResync(seq)
+ }
+ break
+ }
+ if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) {
+ if (seq > snapshot.seq) {
+ clearClineMessagesSnapshot()
+ retryClineMessagesResync(seq)
+ }
+ break
+ }
+ if (message.snapshotTotal !== snapshot.total || snapshot.messages.length !== snapshot.total) {
+ clearClineMessagesSnapshot()
+ retryClineMessagesResync(seq)
+ break
+ }
+
+ clearClineMessagesSnapshot()
+ clearClineMessagesResync()
+ replaceClineMessages(snapshot.messages)
+ clineMessagesSeqRef.current = snapshot.seq
+ setState((prevState) => ({
+ ...prevState,
+ clineMessages: snapshot.messages,
+ clineMessagesSeq: snapshot.seq,
+ }))
+ break
+ }
+ case "clineMessageAppended": {
+ applyClineMessagesDelta(message, "append")
+ break
+ }
+ case "clineMessageUpdated": {
+ // Stryker disable next-line StringLiteral: applyClineMessagesDelta treats every non-"append" operation as an update, so replacing this literal with another non-append string is equivalent.
+ applyClineMessagesDelta(message, "update")
+ break
+ }
+ case "messageUpdated": {
+ // An unsequenced legacy update cannot be applied safely.
+ requestClineMessagesResync(message.clineMessagesSeq)
break
}
case "skills": {
@@ -504,15 +845,31 @@ export const ExtensionStateContextProvider: React.FC<{
}
}
},
- [setListApiConfigMeta],
+ // Stryker disable next-line ArrayDeclaration: the index and callbacks are stable; removing the list does not change this listener closure.
+ [
+ applyClineMessagesDelta,
+ clearClineMessagesSnapshot,
+ clearClineMessagesResync,
+ clineMessagesIndex,
+ requestClineMessagesResync,
+ retryClineMessagesResync,
+ setListApiConfigMeta,
+ startClineMessagesSnapshotTimeout,
+ ],
)
- useEffect(() => {
- window.addEventListener("message", handleMessage)
- return () => {
- window.removeEventListener("message", handleMessage)
- }
- }, [handleMessage])
+ useEffect(
+ () => {
+ window.addEventListener("message", handleMessage)
+ return () => {
+ window.removeEventListener("message", handleMessage)
+ clearClineMessagesSnapshot()
+ clearClineMessagesResync()
+ }
+ },
+ // Stryker disable next-line ArrayDeclaration: both effect dependencies are stable callbacks, making an empty list behaviorally identical for the provider lifetime.
+ [clearClineMessagesResync, clearClineMessagesSnapshot, handleMessage],
+ )
useEffect(() => {
vscode.postMessage({ type: "webviewDidLaunch" })
diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx
index 4c2e2a092c..0078f5386d 100644
--- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx
+++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx
@@ -1,11 +1,20 @@
import { providerIdentifiers } from "@roo-code/types"
-import { render, screen, act } from "@/utils/test-utils"
+import {
+ render,
+ renderHook,
+ screen,
+ act,
+ appendClineMessage,
+ dispatchExtensionMessage,
+ hydrateExtensionState,
+} from "@/utils/test-utils"
import React from "react"
import {
type ProviderSettings,
type ExperimentId,
type ExtensionState,
+ type ExtensionMessage,
type ClineMessage,
type MarketplaceItem,
type MarketplaceInstalledMetadata,
@@ -15,6 +24,9 @@ import {
} from "@roo-code/types"
import { ExtensionStateContextProvider, useExtensionState, mergeExtensionState } from "../ExtensionStateContext"
+import { vscode } from "@/utils/vscode"
+
+const makeMessage = (ts: number, text: string): ClineMessage => ({ ts, type: "say", say: "text", text })
const TestComponent = () => {
const { allowedCommands, setAllowedCommands, soundEnabled, showRooIgnoredFiles, setShowRooIgnoredFiles } =
@@ -105,6 +117,34 @@ const InitialStateTestComponent = () => {
)
}
+const TranscriptTestComponent = () => {
+ const {
+ currentTaskId,
+ currentTaskInstanceId,
+ currentTaskItem,
+ currentTaskTodos,
+ messageQueue,
+ currentCheckpoint,
+ clineMessages,
+ clineMessagesSeq,
+ } = useExtensionState()
+
+ return (
+
+ {JSON.stringify({
+ currentTaskId: currentTaskId ?? null,
+ currentTaskInstanceId,
+ currentTaskItem: currentTaskItem ?? null,
+ currentTaskTodos: currentTaskTodos ?? [],
+ messageQueue: messageQueue ?? [],
+ currentCheckpoint: currentCheckpoint ?? null,
+ clineMessages,
+ clineMessagesSeq: clineMessagesSeq ?? 0,
+ })}
+
+ )
+}
+
describe("ExtensionStateContext", () => {
it("initializes with empty allowedCommands array", () => {
render(
@@ -399,6 +439,2387 @@ describe("ExtensionStateContext", () => {
}),
)
})
+
+ describe("dedicated transcript transport", () => {
+ const readTranscript = () => JSON.parse(screen.getByTestId("transcript-state").textContent!)
+ const readTranscriptFields = () => {
+ const { currentTaskId, clineMessages, clineMessagesSeq } = readTranscript()
+ return { currentTaskId, clineMessages, clineMessagesSeq }
+ }
+ const readScopedTranscriptFields = () => ({
+ ...readTranscriptFields(),
+ currentTaskInstanceId: readTranscript().currentTaskInstanceId,
+ })
+ const renderTranscript = (initialState: Partial = {}) =>
+ render(
+
+
+ ,
+ )
+ const dispatchMalformedExtensionMessage = (message: unknown) =>
+ dispatchExtensionMessage(message as ExtensionMessage)
+ const startSnapshot = (overrides: Record = {}) =>
+ dispatchMalformedExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 2,
+ snapshotId: "snapshot-1",
+ snapshotTotal: 1,
+ ...overrides,
+ })
+ const appendSnapshotChunk = (overrides: Record = {}) =>
+ dispatchMalformedExtensionMessage({
+ type: "clineMessagesSnapshotChunk",
+ taskId: "task-1",
+ clineMessagesSeq: 2,
+ snapshotId: "snapshot-1",
+ snapshotStartIndex: 0,
+ clineMessages: [makeMessage(2, "snapshot")],
+ ...overrides,
+ })
+ const endSnapshot = (overrides: Record = {}) =>
+ dispatchMalformedExtensionMessage({
+ type: "clineMessagesSnapshotEnd",
+ taskId: "task-1",
+ clineMessagesSeq: 2,
+ snapshotId: "snapshot-1",
+ snapshotTotal: 1,
+ ...overrides,
+ })
+ const renderTranscriptWithPostMessageSpy = (initialState: Partial = {}) => {
+ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined)
+ renderTranscript(initialState)
+ postMessage.mockClear()
+ return postMessage
+ }
+ const updateClineMessage = (clineMessage: ClineMessage, clineMessagesSeq: number, taskId?: string) =>
+ dispatchExtensionMessage({ type: "clineMessageUpdated", taskId, clineMessagesSeq, clineMessage })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ vi.useRealTimers()
+ })
+
+ describe("instance-scoped focus", () => {
+ beforeEach(() => vi.useFakeTimers())
+
+ it("does not reopen setup or replace settings when early focus is published", () => {
+ const { result } = renderHook(() => useExtensionState(), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ })
+ const apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.fakeAi }
+ act(() =>
+ dispatchExtensionMessage({
+ type: "state",
+ state: {
+ apiConfiguration,
+ currentTaskId: "task-1",
+ currentTaskInstanceId: "old",
+ soundEnabled: true,
+ },
+ }),
+ )
+ expect(result.current.showWelcome).toBe(false)
+ expect(result.current.didHydrateState).toBe(true)
+ act(() =>
+ dispatchExtensionMessage({
+ type: "clineMessagesFocus",
+ taskId: "task-1",
+ taskInstanceId: "new",
+ }),
+ )
+ expect(result.current.currentTaskInstanceId).toBe("new")
+ expect(result.current.apiConfiguration).toBe(apiConfiguration)
+ expect(result.current.soundEnabled).toBe(true)
+ expect(result.current.showWelcome).toBe(false)
+ })
+
+ it("does not mark initial settings hydrated on early focus publication", () => {
+ const { result } = renderHook(() => useExtensionState(), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ })
+ expect(result.current.didHydrateState).toBe(false)
+ act(() =>
+ dispatchExtensionMessage({
+ type: "clineMessagesFocus",
+ taskId: "task-1",
+ taskInstanceId: "new",
+ }),
+ )
+ expect(result.current.currentTaskInstanceId).toBe("new")
+ expect(result.current.didHydrateState).toBe(false)
+ })
+
+ it("preserves hydrated settings across focus clear and repeated publication", () => {
+ const { result } = renderHook(() => useExtensionState(), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ })
+ act(() =>
+ dispatchExtensionMessage({
+ type: "state",
+ state: {
+ apiConfiguration: { apiProvider: providerIdentifiers.fakeAi },
+ currentTaskId: "task-1",
+ currentTaskInstanceId: "old",
+ soundEnabled: true,
+ },
+ }),
+ )
+ act(() => dispatchExtensionMessage({ type: "clineMessagesFocus" }))
+ expect(result.current.currentTaskId).toBeNull()
+ expect(result.current.currentTaskInstanceId).toBeNull()
+ expect(result.current.clineMessages).toEqual([])
+ expect(result.current.clineMessagesSeq).toBe(0)
+ expect(result.current.showWelcome).toBe(false)
+ expect(result.current.didHydrateState).toBe(true)
+ expect(result.current.soundEnabled).toBe(true)
+ act(() =>
+ dispatchExtensionMessage({ type: "clineMessagesFocus", taskId: "task-1", taskInstanceId: "new" }),
+ )
+ act(() =>
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ taskInstanceId: "new",
+ clineMessagesSeq: 1,
+ clineMessage: makeMessage(1, "new"),
+ }),
+ )
+ const messages = result.current.clineMessages
+ act(() =>
+ dispatchExtensionMessage({ type: "clineMessagesFocus", taskId: "task-1", taskInstanceId: "new" }),
+ )
+ expect(result.current.clineMessages).toBe(messages)
+ expect(result.current.clineMessagesSeq).toBe(1)
+ expect(result.current.showWelcome).toBe(false)
+ })
+
+ it("changes both focus refs before processing frames in the same event batch", () => {
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ currentTaskInstanceId: "instance-1",
+ clineMessages: [makeMessage(10, "old transcript")],
+ clineMessagesSeq: 8,
+ })
+ const updated = makeMessage(20, "current update")
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessagesFocus",
+ taskId: "task-1",
+ taskInstanceId: "instance-2",
+ })
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ taskInstanceId: "instance-1",
+ clineMessagesSeq: 1,
+ clineMessage: makeMessage(10, "stale append"),
+ })
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ taskInstanceId: "instance-2",
+ clineMessagesSeq: 1,
+ clineMessage: makeMessage(20, "current append"),
+ })
+ dispatchExtensionMessage({
+ type: "state",
+ state: { version: "2.0.0", clineMessages: [makeMessage(10, "stale metadata transcript")] },
+ })
+ dispatchExtensionMessage({
+ type: "clineMessageUpdated",
+ taskId: "task-1",
+ taskInstanceId: "instance-2",
+ clineMessagesSeq: 2,
+ clineMessage: updated,
+ })
+ })
+ expect(readScopedTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ currentTaskInstanceId: "instance-2",
+ clineMessages: [updated],
+ clineMessagesSeq: 2,
+ })
+ expect(postMessage.mock.calls).toEqual([])
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it.each<{
+ name: string
+ initialInstanceId?: string
+ state: Partial
+ }>([
+ {
+ name: "same-task replacement",
+ initialInstanceId: "instance-1",
+ state: { currentTaskId: "task-1", currentTaskInstanceId: "instance-2" },
+ },
+ {
+ name: "first instance metadata after legacy initialization",
+ state: { currentTaskId: "task-1", currentTaskInstanceId: "instance-2" },
+ },
+ {
+ name: "instance-only replacement metadata",
+ initialInstanceId: "instance-1",
+ state: { currentTaskInstanceId: "instance-2" },
+ },
+ {
+ name: "explicit instance clear",
+ initialInstanceId: "instance-1",
+ state: { currentTaskInstanceId: null },
+ },
+ ])(
+ "resets messages, sequence, index, snapshot, and resync timers on $name",
+ ({ initialInstanceId, state }) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ currentTaskInstanceId: initialInstanceId,
+ clineMessages: [
+ makeMessage(10, "old first"),
+ makeMessage(20, "old middle"),
+ makeMessage(30, "old last"),
+ ],
+ clineMessagesSeq: 7,
+ })
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ taskInstanceId: initialInstanceId,
+ clineMessagesSeq: 9,
+ clineMessage: makeMessage(40, "old gap"),
+ })
+ startSnapshot({ taskInstanceId: initialInstanceId, clineMessagesSeq: 10 })
+ appendSnapshotChunk({ taskInstanceId: initialInstanceId, clineMessagesSeq: 10 })
+ })
+ expect(postMessage.mock.calls).toEqual([
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 8, receivedSeq: 9 }],
+ ])
+ expect(vi.getTimerCount()).toBe(2)
+ postMessage.mockClear()
+
+ act(() => dispatchExtensionMessage({ type: "state", state }))
+ const cleared = {
+ currentTaskId: "task-1",
+ currentTaskInstanceId: state.currentTaskInstanceId,
+ clineMessages: [],
+ clineMessagesSeq: 0,
+ }
+ expect(readScopedTranscriptFields()).toEqual(cleared)
+ expect(vi.getTimerCount()).toBe(0)
+ act(() => vi.advanceTimersByTime(30_000))
+ expect(postMessage.mock.calls).toEqual([])
+
+ // The old timestamp index must not turn this unknown update into an append.
+ const scope = { taskId: "task-1", taskInstanceId: state.currentTaskInstanceId ?? undefined }
+ act(() =>
+ dispatchExtensionMessage({
+ type: "clineMessageUpdated",
+ ...scope,
+ clineMessagesSeq: 1,
+ clineMessage: makeMessage(20, "removed timestamp"),
+ }),
+ )
+ expect(readScopedTranscriptFields()).toEqual(cleared)
+ expect(postMessage.mock.calls).toEqual([
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 1, receivedSeq: 1 }],
+ ])
+
+ const updated = makeMessage(30, "new position")
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ ...scope,
+ clineMessagesSeq: 1,
+ clineMessage: makeMessage(30, "reused timestamp"),
+ })
+ dispatchExtensionMessage({
+ type: "clineMessageUpdated",
+ ...scope,
+ clineMessagesSeq: 2,
+ clineMessage: updated,
+ })
+ })
+ expect(readScopedTranscriptFields()).toEqual({
+ ...cleared,
+ clineMessages: [updated],
+ clineMessagesSeq: 2,
+ })
+ expect(postMessage.mock.calls).toEqual([
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 1, receivedSeq: 1 }],
+ ])
+ },
+ )
+
+ const frameTypes = [
+ "clineMessageAppended",
+ "clineMessageUpdated",
+ "clineMessagesSnapshotStart",
+ "clineMessagesSnapshotChunk",
+ "clineMessagesSnapshotEnd",
+ ] as const
+ const rejectedScopes = [
+ { identity: "old instance", taskId: "task-1", taskInstanceId: "instance-1" },
+ { identity: "missing instance", taskId: "task-1", taskInstanceId: undefined },
+ { identity: "wrong task", taskId: "task-2", taskInstanceId: "instance-2" },
+ { identity: "missing task", taskId: undefined, taskInstanceId: "instance-2" },
+ ]
+ it.each(
+ frameTypes.flatMap((type) =>
+ ["before", "during", "after"].flatMap((stage) =>
+ rejectedScopes.map((scope) => ({ type, stage, ...scope })),
+ ),
+ ),
+ )("ignores $identity $type $stage the replacement snapshot", ({ type, stage, taskId, taskInstanceId }) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ currentTaskInstanceId: "instance-1",
+ clineMessages: [makeMessage(10, "old transcript")],
+ clineMessagesSeq: 8,
+ })
+ const replacement = makeMessage(20, "replacement")
+ const snapshot = { taskInstanceId: "instance-2", snapshotId: "replacement", clineMessagesSeq: 2 }
+ const buffered = stage === "during" && type === "clineMessagesSnapshotEnd"
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessagesFocus",
+ taskId: "task-1",
+ taskInstanceId: "instance-2",
+ })
+ if (stage !== "before") {
+ startSnapshot(snapshot)
+ if (stage === "after" || buffered) {
+ appendSnapshotChunk({ ...snapshot, clineMessages: [replacement] })
+ }
+ if (stage === "after") {
+ endSnapshot(snapshot)
+ }
+ }
+ })
+ const expected = {
+ currentTaskId: "task-1",
+ currentTaskInstanceId: "instance-2",
+ clineMessages: stage === "after" ? [replacement] : [],
+ clineMessagesSeq: stage === "after" ? 2 : 0,
+ }
+ expect(readScopedTranscriptFields()).toEqual(expected)
+
+ // Chunks/end match the current transaction; starts/deltas are newer so
+ // a missing scope guard would poison it rather than merely look stale.
+ const matchesSnapshot = type === "clineMessagesSnapshotChunk" || type === "clineMessagesSnapshotEnd"
+ const clineMessagesSeq = stage === "before" ? 1 : stage === "during" && matchesSnapshot ? 2 : 3
+ const stale = makeMessage(stage === "after" ? 20 : 10, "stale frame")
+ const frame: ExtensionMessage =
+ type === "clineMessageAppended" || type === "clineMessageUpdated"
+ ? { type, taskId, taskInstanceId, clineMessagesSeq, clineMessage: stale }
+ : {
+ type,
+ taskId,
+ taskInstanceId,
+ clineMessagesSeq,
+ snapshotId: "replacement",
+ ...(type === "clineMessagesSnapshotChunk"
+ ? { snapshotStartIndex: 0, clineMessages: [stale] }
+ : { snapshotTotal: 1 }),
+ }
+ act(() => dispatchExtensionMessage(frame))
+ expect(readScopedTranscriptFields()).toEqual(expected)
+ expect(postMessage.mock.calls).toEqual([])
+ expect(vi.getTimerCount()).toBe(stage === "during" ? 1 : 0)
+
+ act(() => {
+ if (stage === "before") {
+ startSnapshot(snapshot)
+ }
+ if (stage !== "after") {
+ if (!buffered) {
+ appendSnapshotChunk({ ...snapshot, clineMessages: [replacement] })
+ }
+ endSnapshot(snapshot)
+ }
+ })
+ expect(readScopedTranscriptFields()).toEqual({
+ ...expected,
+ clineMessages: [replacement],
+ clineMessagesSeq: 2,
+ })
+
+ const appended = makeMessage(30, "current append")
+ const updated = makeMessage(20, "current update")
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ taskInstanceId: "instance-2",
+ clineMessagesSeq: 3,
+ clineMessage: appended,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessageUpdated",
+ taskId: "task-1",
+ taskInstanceId: "instance-2",
+ clineMessagesSeq: 4,
+ clineMessage: updated,
+ })
+ })
+ expect(readScopedTranscriptFields()).toEqual({
+ ...expected,
+ clineMessages: [updated, appended],
+ clineMessagesSeq: 4,
+ })
+ expect(vi.getTimerCount()).toBe(0)
+ expect(postMessage.mock.calls).toEqual([])
+ })
+
+ it.each>([
+ { version: "2.0.0" },
+ { currentTaskId: "task-1", version: "2.0.0" },
+ { currentTaskId: "task-1", currentTaskInstanceId: undefined },
+ { currentTaskId: "task-1", currentTaskInstanceId: "instance-1" },
+ { currentTaskInstanceId: "instance-1" },
+ ])("preserves the focus, transcript, pending snapshot, and resync through metadata %j", (state) => {
+ const existing = makeMessage(10, "existing")
+ const replacement = makeMessage(20, "replacement")
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ currentTaskInstanceId: "instance-1",
+ clineMessages: [existing],
+ clineMessagesSeq: 3,
+ })
+ const snapshot = { taskInstanceId: "instance-1", clineMessagesSeq: 6 }
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ taskInstanceId: "instance-1",
+ clineMessagesSeq: 5,
+ clineMessage: makeMessage(30, "gap"),
+ })
+ startSnapshot(snapshot)
+ appendSnapshotChunk({ ...snapshot, clineMessages: [replacement] })
+ })
+ expect(vi.getTimerCount()).toBe(2)
+ const clearTimeout = vi.spyOn(window, "clearTimeout")
+ act(() => {
+ dispatchExtensionMessage({
+ type: "state",
+ state: {
+ ...state,
+ clineMessages: [makeMessage(99, "ignored generic transcript")],
+ clineMessagesSeq: 99,
+ },
+ })
+ dispatchExtensionMessage({ type: "messageUpdated", clineMessagesSeq: 99 })
+ })
+ expect(readScopedTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ currentTaskInstanceId: "instance-1",
+ clineMessages: [existing],
+ clineMessagesSeq: 3,
+ })
+ expect(clearTimeout.mock.calls).toEqual([])
+ expect(vi.getTimerCount()).toBe(2)
+ expect(postMessage.mock.calls).toEqual([
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 4, receivedSeq: 5 }],
+ ])
+
+ const updated = makeMessage(20, "updated replacement")
+ act(() => {
+ endSnapshot(snapshot)
+ dispatchExtensionMessage({
+ type: "clineMessageUpdated",
+ taskId: "task-1",
+ taskInstanceId: "instance-1",
+ clineMessagesSeq: 7,
+ clineMessage: updated,
+ })
+ })
+ expect(readScopedTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ currentTaskInstanceId: "instance-1",
+ clineMessages: [updated],
+ clineMessagesSeq: 7,
+ })
+ expect(vi.getTimerCount()).toBe(0)
+ expect(postMessage.mock.calls).toEqual([
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 4, receivedSeq: 5 }],
+ ])
+ })
+
+ it.each(["task-2", null])("clears an omitted instance on a switch to %s", (currentTaskId) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ currentTaskInstanceId: "instance-1",
+ clineMessages: [makeMessage(10, "old")],
+ clineMessagesSeq: 8,
+ })
+ act(() => dispatchExtensionMessage({ type: "state", state: { currentTaskId } }))
+ const cleared = {
+ currentTaskId,
+ currentTaskInstanceId: currentTaskId === null ? null : undefined,
+ clineMessages: [],
+ clineMessagesSeq: 0,
+ }
+ expect(readScopedTranscriptFields()).toEqual(cleared)
+ act(() => dispatchExtensionMessage({ type: "state", state: { version: "2.0.0" } }))
+ expect(readScopedTranscriptFields()).toEqual(cleared)
+
+ const snapshot = { taskId: currentTaskId ?? undefined, clineMessagesSeq: 0, snapshotTotal: 0 }
+ act(() => startSnapshot({ ...snapshot, taskInstanceId: "instance-1" }))
+ expect(vi.getTimerCount()).toBe(0)
+ act(() => startSnapshot(snapshot))
+ expect(vi.getTimerCount()).toBe(currentTaskId === null ? 0 : 1)
+ act(() => endSnapshot(snapshot))
+ expect(vi.getTimerCount()).toBe(0)
+ expect(readScopedTranscriptFields()).toEqual(cleared)
+ expect(postMessage.mock.calls).toEqual([])
+ })
+
+ it.each(
+ frameTypes.flatMap((type) => ["state", "clineMessagesFocus"].map((clearType) => ({ type, clearType }))),
+ )("ignores an unscoped $type after task clearing via $clearType", ({ type, clearType }) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ currentTaskInstanceId: "instance-1",
+ clineMessages: [makeMessage(10, "old transcript")],
+ clineMessagesSeq: 8,
+ })
+ const stale = makeMessage(10, "unscoped frame")
+ act(() => {
+ dispatchExtensionMessage(
+ clearType === "state"
+ ? { type: "state", state: { currentTaskId: null } }
+ : { type: "clineMessagesFocus" },
+ )
+ // Omit both identity fields and deliver before React renders the clear.
+ dispatchExtensionMessage(
+ type === "clineMessageAppended" || type === "clineMessageUpdated"
+ ? { type, clineMessagesSeq: 1, clineMessage: stale }
+ : {
+ type,
+ clineMessagesSeq: 1,
+ snapshotId: "unscoped",
+ ...(type === "clineMessagesSnapshotChunk"
+ ? { snapshotStartIndex: 0, clineMessages: [stale] }
+ : { snapshotTotal: 1 }),
+ },
+ )
+ })
+
+ expect(readScopedTranscriptFields()).toEqual({
+ currentTaskId: null,
+ currentTaskInstanceId: null,
+ clineMessages: [],
+ clineMessagesSeq: 0,
+ })
+ expect(vi.getTimerCount()).toBe(0)
+ act(() => vi.advanceTimersByTime(30_000))
+ expect(postMessage).not.toHaveBeenCalled()
+ })
+
+ it.each(["state", "clineMessagesFocus"])(
+ "rejects a nonempty unscoped snapshot after task clearing via %s",
+ (clearType) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ currentTaskInstanceId: "instance-1",
+ clineMessages: [makeMessage(10, "old transcript")],
+ clineMessagesSeq: 8,
+ })
+ const pending = { taskInstanceId: "instance-1", clineMessagesSeq: 9 }
+ act(() => {
+ startSnapshot(pending)
+ appendSnapshotChunk(pending)
+ })
+ expect(vi.getTimerCount()).toBe(1)
+
+ act(() => {
+ dispatchExtensionMessage(
+ clearType === "state"
+ ? { type: "state", state: { currentTaskId: null } }
+ : { type: "clineMessagesFocus" },
+ )
+ const unscoped = { taskId: undefined, clineMessagesSeq: 1, snapshotId: "unscoped" }
+ startSnapshot(unscoped)
+ appendSnapshotChunk({ ...unscoped, clineMessages: [makeMessage(20, "unscoped snapshot")] })
+ endSnapshot(unscoped)
+ })
+
+ expect(readScopedTranscriptFields()).toEqual({
+ currentTaskId: null,
+ currentTaskInstanceId: null,
+ clineMessages: [],
+ clineMessagesSeq: 0,
+ })
+ expect(vi.getTimerCount()).toBe(0)
+ act(() => vi.advanceTimersByTime(30_000))
+ expect(postMessage).not.toHaveBeenCalled()
+ },
+ )
+
+ it.each<{
+ name: string
+ initialState: Partial
+ expectedInstanceId: string | null | undefined
+ }>([
+ { name: "initial partial state", initialState: {}, expectedInstanceId: undefined },
+ { name: "legacy task", initialState: { currentTaskId: "task-1" }, expectedInstanceId: undefined },
+ {
+ name: "scoped task",
+ initialState: { currentTaskId: "task-1", currentTaskInstanceId: "instance-1" },
+ expectedInstanceId: "instance-1",
+ },
+ {
+ name: "explicit no-task with an obsolete instance",
+ initialState: { currentTaskId: null, currentTaskInstanceId: "instance-1" },
+ expectedInstanceId: null,
+ },
+ ])("initializes and preserves the scope for $name", ({ initialState, expectedInstanceId }) => {
+ const { result } = renderHook(() => useExtensionState(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ })
+ expect(result.current.currentTaskId).toBe(initialState.currentTaskId)
+ expect(result.current.currentTaskInstanceId).toBe(expectedInstanceId)
+ const messages = result.current.clineMessages
+ act(() => dispatchExtensionMessage({ type: "state", state: { version: "2.0.0" } }))
+ expect(result.current.currentTaskId).toBe(initialState.currentTaskId)
+ expect(result.current.currentTaskInstanceId).toBe(expectedInstanceId)
+ expect(result.current.clineMessages).toBe(messages)
+ expect(result.current.version).toBe("2.0.0")
+
+ const snapshot = {
+ taskId: initialState.currentTaskId ?? undefined,
+ taskInstanceId: expectedInstanceId ?? undefined,
+ clineMessagesSeq: 0,
+ snapshotTotal: 0,
+ }
+ act(() => startSnapshot(snapshot))
+ expect(vi.getTimerCount()).toBe(initialState.currentTaskId ? 1 : 0)
+ act(() => endSnapshot(snapshot))
+ expect(vi.getTimerCount()).toBe(0)
+ expect(result.current.clineMessages).toEqual([])
+ expect(result.current.clineMessagesSeq).toBe(initialState.currentTaskId ? 0 : undefined)
+ })
+ })
+
+ it.each(["initial state", "appends", "snapshot"])(
+ "updates first, middle, and last timestamps after %s, including repeated updates",
+ (source) => {
+ const messages = Array.from({ length: 5 }, (_, index) => makeMessage(index, `message ${index}`))
+ Object.freeze(messages)
+ const postMessage = renderTranscriptWithPostMessageSpy(
+ source === "initial state" ? { clineMessages: messages, clineMessagesSeq: 5 } : {},
+ )
+ act(() => {
+ if (source === "appends") {
+ messages.forEach((message, index) => appendClineMessage(message, index + 1, "task-1"))
+ } else if (source === "snapshot") {
+ hydrateExtensionState({ clineMessages: messages, clineMessagesSeq: 5 }, { taskId: "task-1" })
+ }
+ })
+
+ let expectedMessages = messages
+ let seq = 5
+ for (const index of [0, 2, 4]) {
+ for (const text of ["updated", "updated again"]) {
+ const updated = makeMessage(index, text)
+ seq += 1
+ act(() => updateClineMessage(updated, seq, "task-1"))
+ expectedMessages = expectedMessages.map((message, position) =>
+ position === index ? updated : message,
+ )
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: expectedMessages,
+ clineMessagesSeq: seq,
+ })
+ }
+ }
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(messages).toEqual(
+ Array.from({ length: 5 }, (_, index) => makeMessage(index, `message ${index}`)),
+ )
+ },
+ )
+
+ it("looks up updates without rereading transcript timestamps or rebuilding the index on render", () => {
+ const readTimestamp = vi.fn((ts: number) => ts)
+ const messages: ClineMessage[] = Array.from({ length: 1_000 }, (_, index) => ({
+ ...makeMessage(index, `message ${index}`),
+ get ts() {
+ return readTimestamp(index)
+ },
+ }))
+ const { result } = renderHook(() => useExtensionState(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ })
+
+ for (const [offset, index] of [0, 500, 999].entries()) {
+ const previous = result.current.clineMessages
+ const updated = makeMessage(index, "updated")
+ readTimestamp.mockClear()
+ act(() => updateClineMessage(updated, offset + 2, "task-1"))
+
+ expect(readTimestamp).not.toHaveBeenCalled()
+ expect(result.current.clineMessages === previous).toBe(false)
+ expect(result.current.clineMessages[index]).toBe(updated)
+ expect(previous[index]).toBe(messages[index])
+ expect(result.current.clineMessages[1]).toBe(messages[1])
+ expect(result.current.clineMessagesSeq).toBe(offset + 2)
+ }
+ })
+
+ it("rebuilds moved timestamps and drops removed timestamps when a replacement snapshot commits", () => {
+ const original = [makeMessage(10, "first"), makeMessage(20, "removed"), makeMessage(30, "last")]
+ const replacement = [makeMessage(30, "moved first"), makeMessage(40, "new"), makeMessage(10, "moved last")]
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: original, clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot({ snapshotTotal: 3 })
+ appendSnapshotChunk({ clineMessages: replacement })
+ })
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: original,
+ clineMessagesSeq: 1,
+ })
+
+ const updatedFirst = makeMessage(30, "updated first")
+ const updatedMiddle = makeMessage(40, "updated middle")
+ const updatedLast = makeMessage(10, "updated last")
+ act(() => {
+ endSnapshot({ snapshotTotal: 3 })
+ updateClineMessage(updatedFirst, 3, "task-1")
+ updateClineMessage(updatedMiddle, 4, "task-1")
+ updateClineMessage(updatedLast, 5, "task-1")
+ })
+ expect(postMessage).not.toHaveBeenCalled()
+ const committed = {
+ currentTaskId: "task-1",
+ clineMessages: [updatedFirst, updatedMiddle, updatedLast],
+ clineMessagesSeq: 5,
+ }
+ expect(readTranscriptFields()).toEqual(committed)
+
+ act(() => updateClineMessage(makeMessage(20, "stale timestamp"), 6, "task-1"))
+ expect(readTranscriptFields()).toEqual(committed)
+ expect(postMessage.mock.calls).toEqual([
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 6, receivedSeq: 6 }],
+ ])
+ })
+
+ it("drops all timestamp entries when an empty snapshot replaces the transcript", () => {
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ clineMessages: [makeMessage(10, "old")],
+ clineMessagesSeq: 1,
+ })
+ act(() => {
+ startSnapshot({ snapshotTotal: 0 })
+ endSnapshot({ snapshotTotal: 0 })
+ updateClineMessage(makeMessage(10, "stale timestamp"), 3, "task-1")
+ })
+
+ expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 2 })
+ expect(postMessage.mock.calls).toEqual([
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 3, receivedSeq: 3 }],
+ ])
+ })
+
+ it.each([
+ { name: "task switch", initialTaskId: "task-1", nextTaskId: "task-2" },
+ { name: "task clear", initialTaskId: "task-1", nextTaskId: null },
+ { name: "repeated no-task clear", initialTaskId: null, nextTaskId: null },
+ ])(
+ "clears stale timestamp entries after $name and indexes subsequent appends",
+ ({ initialTaskId, nextTaskId }) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ currentTaskId: initialTaskId,
+ clineMessages: [makeMessage(10, "first"), makeMessage(20, "middle"), makeMessage(30, "last")],
+ clineMessagesSeq: 3,
+ })
+ const taskId = nextTaskId ?? undefined
+ act(() => {
+ dispatchExtensionMessage({ type: "state", state: { currentTaskId: nextTaskId } })
+ updateClineMessage(makeMessage(20, "stale timestamp"), 1, taskId)
+ })
+
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: nextTaskId,
+ clineMessages: [],
+ clineMessagesSeq: 0,
+ })
+ expect(postMessage.mock.calls).toEqual(
+ nextTaskId === null
+ ? []
+ : [[{ type: "requestClineMessagesResync", taskId, expectedSeq: 1, receivedSeq: 1 }]],
+ )
+ postMessage.mockClear()
+
+ // Transcript delivery resumes only after a task becomes active again.
+ const activeTaskId = nextTaskId ?? "task-2"
+ const updated = makeMessage(30, "updated at new position")
+ act(() => {
+ dispatchExtensionMessage({ type: "state", state: { currentTaskId: activeTaskId } })
+ appendClineMessage(makeMessage(30, "reused timestamp"), 1, activeTaskId)
+ updateClineMessage(updated, 2, activeTaskId)
+ })
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: activeTaskId,
+ clineMessages: [updated],
+ clineMessagesSeq: 2,
+ })
+ expect(postMessage).not.toHaveBeenCalled()
+ },
+ )
+
+ it("preserves last-match timestamp semantics across initialization, appends, and snapshot replacement", () => {
+ const first = makeMessage(10, "earlier duplicate")
+ const last = makeMessage(10, "last duplicate")
+ const updated = makeMessage(10, "updated")
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ clineMessages: [first, last],
+ clineMessagesSeq: 1,
+ })
+
+ act(() => updateClineMessage(updated, 2, "task-1"))
+ expect(readTranscript().clineMessages).toEqual([first, updated])
+
+ const updatedAppend = makeMessage(10, "updated append")
+ act(() => {
+ appendClineMessage(last, 3, "task-1")
+ updateClineMessage(updatedAppend, 4, "task-1")
+ })
+ expect(readTranscript().clineMessages).toEqual([first, updated, updatedAppend])
+
+ act(() => {
+ hydrateExtensionState({ clineMessages: [first, last], clineMessagesSeq: 5 }, { taskId: "task-1" })
+ updateClineMessage(updated, 6, "task-1")
+ })
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [first, updated],
+ clineMessagesSeq: 6,
+ })
+ expect(postMessage).not.toHaveBeenCalled()
+ })
+
+ it.each<{ name: string; message: ExtensionMessage; requestsResync: boolean }>([
+ {
+ name: "partial generic state",
+ message: {
+ type: "state",
+ state: { clineMessages: [makeMessage(30, "ignored replacement")], clineMessagesSeq: 99 },
+ },
+ requestsResync: false,
+ },
+ {
+ name: "same-task generic state",
+ message: {
+ type: "state",
+ state: {
+ currentTaskId: "task-1",
+ clineMessages: [makeMessage(30, "ignored replacement")],
+ clineMessagesSeq: 99,
+ },
+ },
+ requestsResync: false,
+ },
+ {
+ name: "legacy message update",
+ message: { type: "messageUpdated", clineMessage: makeMessage(20, "ignored update") },
+ requestsResync: true,
+ },
+ ])("preserves the timestamp index through $name", ({ message, requestsResync }) => {
+ const original = [makeMessage(10, "first"), makeMessage(20, "middle"), makeMessage(30, "last")]
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: original, clineMessagesSeq: 3 })
+
+ act(() => dispatchExtensionMessage(message))
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: original,
+ clineMessagesSeq: 3,
+ })
+ expect(postMessage.mock.calls).toEqual(
+ requestsResync
+ ? [
+ [
+ {
+ type: "requestClineMessagesResync",
+ taskId: "task-1",
+ expectedSeq: 4,
+ receivedSeq: undefined,
+ },
+ ],
+ ]
+ : [],
+ )
+ postMessage.mockClear()
+
+ const updated = original.map((entry) => makeMessage(entry.ts, "updated"))
+ act(() => updated.forEach((entry, index) => updateClineMessage(entry, index + 4, "task-1")))
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: updated,
+ clineMessagesSeq: 6,
+ })
+ expect(postMessage).not.toHaveBeenCalled()
+ })
+
+ it("ignores a delta for a different task", () => {
+ const existing = makeMessage(1, "existing")
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 })
+
+ act(() => appendClineMessage(makeMessage(2, "wrong task"), 2, "task-2"))
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [existing],
+ clineMessagesSeq: 1,
+ })
+ })
+
+ it.each([
+ {
+ name: "a missing sequence",
+ seq: undefined,
+ receivedSeq: undefined,
+ clineMessage: makeMessage(2, "next"),
+ },
+ { name: "a nonnumeric sequence", seq: "2", receivedSeq: undefined, clineMessage: makeMessage(2, "next") },
+ { name: "a boolean sequence", seq: true, receivedSeq: undefined, clineMessage: makeMessage(2, "next") },
+ { name: "a fractional sequence", seq: 1.5, receivedSeq: 1.5, clineMessage: makeMessage(2, "next") },
+ { name: "a negative sequence", seq: -1, receivedSeq: -1, clineMessage: makeMessage(2, "next") },
+ { name: "a missing message", seq: 2, receivedSeq: 2, clineMessage: undefined },
+ ])("requests resynchronization for $name in a delta", ({ seq, receivedSeq, clineMessage }) => {
+ const existing = makeMessage(1, "existing")
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 })
+
+ act(() =>
+ dispatchMalformedExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ clineMessagesSeq: seq,
+ clineMessage,
+ }),
+ )
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith({
+ type: "requestClineMessagesResync",
+ taskId: "task-1",
+ expectedSeq: 2,
+ receivedSeq,
+ })
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [existing],
+ clineMessagesSeq: 1,
+ })
+ })
+
+ it.each([0, 1])("ignores stale delta sequence %s", (seq) => {
+ const existing = makeMessage(1, "existing")
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 })
+
+ act(() => appendClineMessage(makeMessage(2, "stale"), seq, "task-1"))
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [existing],
+ clineMessagesSeq: 1,
+ })
+ })
+
+ it.each([
+ { name: "an explicit same-task update", state: { currentTaskId: "task-1", version: "2.0.0" } },
+ { name: "a partial metadata update", state: { version: "2.0.0" } },
+ ])("preserves transcript refs through $name", ({ state }) => {
+ const existing = makeMessage(1, "existing")
+ const next = makeMessage(2, "next")
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 3 })
+
+ act(() => {
+ dispatchMalformedExtensionMessage({ type: "state", state })
+ appendClineMessage(next, 4, "task-1")
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [existing, next],
+ clineMessagesSeq: 4,
+ })
+ })
+
+ it("starts the replacement task with an empty transcript ref", () => {
+ const next = makeMessage(2, "replacement task")
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ clineMessages: [makeMessage(1, "existing")],
+ clineMessagesSeq: 3,
+ })
+
+ act(() => {
+ dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } })
+ appendClineMessage(next, 1, "task-2")
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-2",
+ clineMessages: [next],
+ clineMessagesSeq: 1,
+ })
+ })
+
+ it("does not clear a nonexistent resync timeout during a task switch", () => {
+ vi.useFakeTimers()
+ const clearTimeout = vi.spyOn(window, "clearTimeout")
+ renderTranscript({ clineMessagesSeq: 1 })
+
+ act(() => dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }))
+
+ expect(clearTimeout).not.toHaveBeenCalled()
+ })
+
+ it("clears a pending resync before requesting recovery for a replacement task", () => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+ const clearTimeout = vi.spyOn(window, "clearTimeout")
+
+ act(() => appendClineMessage(makeMessage(3, "old gap"), 3, "task-1"))
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ const pendingTimerCount = vi.getTimerCount()
+ act(() => {
+ dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } })
+ appendClineMessage(makeMessage(2, "new gap"), 2, "task-2")
+ })
+
+ expect(pendingTimerCount).toBe(1)
+ expect(clearTimeout).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledTimes(2)
+ expect(postMessage).toHaveBeenLastCalledWith({
+ type: "requestClineMessagesResync",
+ taskId: "task-2",
+ expectedSeq: 1,
+ receivedSeq: 2,
+ })
+ })
+
+ it("clears a pending resync timeout when the provider unmounts", () => {
+ vi.useFakeTimers()
+ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined)
+ const clearTimeout = vi.spyOn(window, "clearTimeout")
+ const { unmount } = renderTranscript({ clineMessagesSeq: 1 })
+ postMessage.mockClear()
+
+ act(() => appendClineMessage(makeMessage(3, "gap"), 3, "task-1"))
+ clearTimeout.mockClear()
+ unmount()
+
+ expect(clearTimeout).toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it("does not clear a nonexistent snapshot timeout when the first snapshot starts", () => {
+ vi.useFakeTimers()
+ renderTranscript({ clineMessagesSeq: 1 })
+ const clearTimeout = vi.spyOn(window, "clearTimeout")
+
+ act(() => startSnapshot())
+
+ expect(clearTimeout).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(1)
+ })
+
+ it("abandons an incomplete replacement snapshot without changing the transcript or applied sequence", () => {
+ vi.useFakeTimers()
+ const existing = [makeMessage(1, "existing first"), makeMessage(2, "existing last")]
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: existing, clineMessagesSeq: 7 })
+ const unchanged = { currentTaskId: "task-1", clineMessages: existing, clineMessagesSeq: 7 }
+
+ act(() => {
+ startSnapshot({ clineMessagesSeq: 10, snapshotTotal: 3 })
+ appendSnapshotChunk({
+ clineMessagesSeq: 10,
+ clineMessages: [makeMessage(2, "partial replacement")],
+ })
+ })
+ expect(readTranscriptFields()).toEqual(unchanged)
+
+ act(() => vi.advanceTimersByTime(29_999))
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscriptFields()).toEqual(unchanged)
+
+ act(() => vi.advanceTimersByTime(1))
+ expect(readTranscriptFields()).toEqual(unchanged)
+ expect(postMessage.mock.calls).toEqual([
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 8, receivedSeq: 10 }],
+ ])
+ expect(vi.getTimerCount()).toBe(1)
+
+ act(() => vi.advanceTimersByTime(30_000))
+ expect(readTranscriptFields()).toEqual(unchanged)
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(vi.getTimerCount()).toBe(0)
+
+ // The discarded snapshot must not change the index or sequence used by the next delta.
+ const updated = makeMessage(1, "updated after timeout")
+ act(() => updateClineMessage(updated, 8, "task-1"))
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [updated, existing[1]],
+ clineMessagesSeq: 8,
+ })
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ })
+
+ it("clears the snapshot timeout when a snapshot completes", () => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ appendSnapshotChunk()
+ endSnapshot()
+ vi.advanceTimersByTime(30_000)
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [makeMessage(2, "snapshot")],
+ clineMessagesSeq: 2,
+ })
+ })
+
+ it("restarts the snapshot timeout when a replacement snapshot starts", () => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+ const replacement = makeMessage(3, "replacement")
+
+ act(() => {
+ startSnapshot()
+ vi.advanceTimersByTime(20_000)
+ startSnapshot({ clineMessagesSeq: 3, snapshotId: "replacement" })
+ vi.advanceTimersByTime(20_000)
+ appendSnapshotChunk({
+ clineMessagesSeq: 3,
+ snapshotId: "replacement",
+ clineMessages: [replacement],
+ })
+ endSnapshot({ clineMessagesSeq: 3, snapshotId: "replacement" })
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [replacement],
+ clineMessagesSeq: 3,
+ })
+ })
+
+ it("clears the snapshot timeout when a newer delta invalidates the snapshot", () => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ appendClineMessage(makeMessage(3, "newer delta"), 3, "task-1")
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }),
+ )
+ expect(vi.getTimerCount()).toBe(1)
+ })
+
+ it("clears the snapshot timeout when the task changes", () => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } })
+ vi.advanceTimersByTime(30_000)
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it("clears the snapshot timeout when the provider unmounts", () => {
+ vi.useFakeTimers()
+ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined)
+ const { unmount } = renderTranscript({ clineMessagesSeq: 1 })
+ postMessage.mockClear()
+
+ act(() => startSnapshot())
+ expect(vi.getTimerCount()).toBe(1)
+ unmount()
+
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it.each([
+ { name: "a replacement ID", clineMessagesSeq: 2, snapshotId: "replacement" },
+ { name: "a replacement sequence", clineMessagesSeq: 3, snapshotId: "snapshot-1" },
+ ])("ignores a stale timeout callback after $name takes ownership", ({ clineMessagesSeq, snapshotId }) => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+ const setTimeout = vi.spyOn(window, "setTimeout")
+
+ act(() => startSnapshot())
+ const staleTimeout = setTimeout.mock.calls[0]?.[0]
+ if (typeof staleTimeout !== "function") {
+ throw new Error("Expected the snapshot timeout callback to be scheduled")
+ }
+ const replacement = makeMessage(clineMessagesSeq, "replacement")
+
+ act(() => {
+ startSnapshot({ clineMessagesSeq, snapshotId })
+ staleTimeout()
+ appendSnapshotChunk({ clineMessagesSeq, snapshotId, clineMessages: [replacement] })
+ endSnapshot({ clineMessagesSeq, snapshotId })
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [replacement],
+ clineMessagesSeq,
+ })
+ })
+
+ it("ignores a stale timeout callback after its snapshot is cleared", () => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+ const setTimeout = vi.spyOn(window, "setTimeout")
+
+ act(() => startSnapshot())
+ const staleTimeout = setTimeout.mock.calls[0]?.[0]
+ if (typeof staleTimeout !== "function") {
+ throw new Error("Expected the snapshot timeout callback to be scheduled")
+ }
+
+ act(() => dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }))
+ expect(vi.getTimerCount()).toBe(0)
+ expect(() => act(() => staleTimeout())).not.toThrow()
+ expect(postMessage).not.toHaveBeenCalled()
+ })
+
+ it.each([
+ { name: "a nonnumeric sequence", overrides: { clineMessagesSeq: "2" }, receivedSeq: undefined },
+ { name: "a boolean sequence", overrides: { clineMessagesSeq: true }, receivedSeq: undefined },
+ { name: "a fractional sequence", overrides: { clineMessagesSeq: 1.5 }, receivedSeq: 1.5 },
+ { name: "a negative sequence", overrides: { clineMessagesSeq: -1 }, receivedSeq: -1 },
+ ])("rejects a snapshot start with $name", ({ overrides, receivedSeq }) => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ startSnapshot(overrides)
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith({
+ type: "requestClineMessagesResync",
+ taskId: "task-1",
+ expectedSeq: 2,
+ receivedSeq,
+ })
+ expect(vi.getTimerCount()).toBe(1)
+ })
+
+ it.each([
+ { name: "a missing snapshot ID", overrides: { snapshotId: "" } },
+ { name: "a nonnumeric total", overrides: { snapshotTotal: "1" } },
+ { name: "a boolean total", overrides: { snapshotTotal: true } },
+ { name: "a fractional total", overrides: { snapshotTotal: 1.5 } },
+ { name: "a negative total", overrides: { snapshotTotal: -1 } },
+ ])("rejects a snapshot start with $name", ({ overrides }) => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ startSnapshot(overrides)
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }),
+ )
+ expect(vi.getTimerCount()).toBe(1)
+ })
+
+ it("rejects a snapshot start for a different task before it can accept current-task chunks", () => {
+ const existing = makeMessage(1, "existing")
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot({ taskId: "task-2", clineMessagesSeq: 3, snapshotId: "wrong-task" })
+ appendSnapshotChunk({ clineMessagesSeq: 3, snapshotId: "wrong-task" })
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }),
+ )
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [existing],
+ clineMessagesSeq: 1,
+ })
+ })
+
+ it("ignores a snapshot older than the applied transcript", () => {
+ const existing = makeMessage(1, "existing")
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 2 })
+
+ act(() => {
+ startSnapshot({ clineMessagesSeq: 1, snapshotTotal: 0, snapshotId: "stale" })
+ endSnapshot({ clineMessagesSeq: 1, snapshotTotal: 0, snapshotId: "stale" })
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [existing],
+ clineMessagesSeq: 2,
+ })
+ })
+
+ it("ignores a duplicate start without discarding collected chunks", () => {
+ renderTranscript({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ appendSnapshotChunk()
+ startSnapshot()
+ endSnapshot()
+ })
+
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [makeMessage(2, "snapshot")],
+ clineMessagesSeq: 2,
+ })
+ })
+
+ it("ignores an older start without replacing the active snapshot", () => {
+ renderTranscript({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot({ clineMessagesSeq: 3, snapshotId: "newer" })
+ appendSnapshotChunk({ clineMessagesSeq: 3, snapshotId: "newer" })
+ startSnapshot({ clineMessagesSeq: 2, snapshotId: "older" })
+ endSnapshot({ clineMessagesSeq: 3, snapshotId: "newer" })
+ })
+
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [makeMessage(2, "snapshot")],
+ clineMessagesSeq: 3,
+ })
+ })
+
+ it("replaces an active snapshot when the same ID arrives at a newer sequence", () => {
+ const replacement = makeMessage(3, "replacement")
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot({ clineMessagesSeq: 2, snapshotId: "reused-id" })
+ startSnapshot({ clineMessagesSeq: 3, snapshotId: "reused-id" })
+ appendSnapshotChunk({ clineMessagesSeq: 3, snapshotId: "reused-id", clineMessages: [replacement] })
+ endSnapshot({ clineMessagesSeq: 3, snapshotId: "reused-id" })
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [replacement],
+ clineMessagesSeq: 3,
+ })
+ })
+
+ it.each([
+ { name: "the same sequence uses a replacement ID", seq: 2, snapshotId: "replacement" },
+ { name: "a newer sequence starts", seq: 3, snapshotId: "newer" },
+ ])("replaces an active snapshot when $name", ({ seq, snapshotId }) => {
+ const replacement = makeMessage(seq, "replacement")
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ startSnapshot({ clineMessagesSeq: seq, snapshotId })
+ appendSnapshotChunk({ clineMessagesSeq: seq, snapshotId, clineMessages: [replacement] })
+ endSnapshot({ clineMessagesSeq: seq, snapshotId })
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [replacement],
+ clineMessagesSeq: seq,
+ })
+ })
+
+ it("accepts sequence zero throughout a complete snapshot", () => {
+ const message = makeMessage(1, "initial snapshot")
+ const postMessage = renderTranscriptWithPostMessageSpy()
+
+ act(() => {
+ startSnapshot({ clineMessagesSeq: 0 })
+ appendSnapshotChunk({ clineMessagesSeq: 0, clineMessages: [message] })
+ endSnapshot({ clineMessagesSeq: 0 })
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [message],
+ clineMessagesSeq: 0,
+ })
+ })
+
+ it.each([
+ { name: "a nonnumeric sequence", seq: "2", receivedSeq: undefined },
+ { name: "a boolean sequence", seq: true, receivedSeq: undefined },
+ { name: "a fractional sequence", seq: 1.5, receivedSeq: 1.5 },
+ { name: "a negative sequence", seq: -1, receivedSeq: -1 },
+ ])("rejects a snapshot chunk with $name", ({ seq, receivedSeq }) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ appendSnapshotChunk({ clineMessagesSeq: seq })
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith({
+ type: "requestClineMessagesResync",
+ taskId: "task-1",
+ expectedSeq: 2,
+ receivedSeq,
+ })
+ })
+
+ it("invalidates an active snapshot after a malformed chunk", () => {
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ appendSnapshotChunk({ clineMessagesSeq: "invalid" })
+ appendSnapshotChunk()
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(2)
+ expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined, 2])
+ })
+
+ it.each([
+ { name: "a missing snapshot", seq: 2, shouldResync: true },
+ { name: "a stale missing snapshot", seq: 1, shouldResync: false },
+ { name: "an equal missing snapshot", seq: 2, shouldResync: false, initialSeq: 2 },
+ ])("handles a chunk with $name", ({ seq, shouldResync, initialSeq = 1 }) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: initialSeq })
+
+ act(() => appendSnapshotChunk({ clineMessagesSeq: seq, snapshotId: "missing" }))
+
+ if (shouldResync) {
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: seq }),
+ )
+ } else {
+ expect(postMessage).not.toHaveBeenCalled()
+ }
+ })
+
+ it.each([
+ {
+ name: "a newer sequence",
+ overrides: { clineMessagesSeq: 3 },
+ expectedResyncSeq: 3,
+ },
+ {
+ name: "a newer ID and sequence",
+ overrides: { snapshotId: "newer", clineMessagesSeq: 3 },
+ expectedResyncSeq: 3,
+ },
+ ])("restarts after a chunk with $name", ({ overrides, expectedResyncSeq }) => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ appendSnapshotChunk(overrides)
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: expectedResyncSeq }),
+ )
+ expect(vi.getTimerCount()).toBe(1)
+ })
+
+ it.each([
+ { name: "an older sequence", overrides: { clineMessagesSeq: 1 } },
+ { name: "a different ID at the same sequence", overrides: { snapshotId: "other" } },
+ ])("ignores a chunk with $name and preserves the active snapshot", ({ overrides }) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ appendSnapshotChunk(overrides)
+ appendSnapshotChunk()
+ endSnapshot()
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [makeMessage(2, "snapshot")],
+ clineMessagesSeq: 2,
+ })
+ })
+
+ it.each([
+ { name: "a non-array payload", overrides: { clineMessages: "message" } },
+ { name: "an empty payload", overrides: { clineMessages: [] } },
+ { name: "a nonnumeric start index", overrides: { snapshotStartIndex: "0" } },
+ { name: "a boolean start index", overrides: { snapshotStartIndex: true } },
+ { name: "a fractional start index", overrides: { snapshotStartIndex: 0.5 } },
+ { name: "a noncontiguous start index", overrides: { snapshotStartIndex: 1 } },
+ {
+ name: "messages beyond the declared total",
+ overrides: { clineMessages: [makeMessage(2, "first"), makeMessage(3, "overflow")] },
+ },
+ ])("rejects a snapshot chunk with $name", ({ overrides }) => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ appendSnapshotChunk(overrides)
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }),
+ )
+ expect(vi.getTimerCount()).toBe(1)
+ })
+
+ it.each([
+ { name: "a nonnumeric sequence", seq: "2", receivedSeq: undefined },
+ { name: "a boolean sequence", seq: true, receivedSeq: undefined },
+ { name: "a fractional sequence", seq: 1.5, receivedSeq: 1.5 },
+ { name: "a negative sequence", seq: -1, receivedSeq: -1 },
+ ])("rejects a snapshot end with $name", ({ seq, receivedSeq }) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ endSnapshot({ clineMessagesSeq: seq })
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith({
+ type: "requestClineMessagesResync",
+ taskId: "task-1",
+ expectedSeq: 2,
+ receivedSeq,
+ })
+ })
+
+ it("invalidates an active snapshot after a malformed end", () => {
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ endSnapshot({ clineMessagesSeq: "invalid" })
+ appendSnapshotChunk()
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(2)
+ expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined, 2])
+ })
+
+ it.each([
+ { name: "a missing snapshot", seq: 2, shouldResync: true },
+ { name: "a stale missing snapshot", seq: 1, shouldResync: false },
+ { name: "an equal missing snapshot", seq: 2, shouldResync: false, initialSeq: 2 },
+ ])("handles an end with $name", ({ seq, shouldResync, initialSeq = 1 }) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: initialSeq })
+
+ act(() => endSnapshot({ clineMessagesSeq: seq, snapshotId: "missing" }))
+
+ if (shouldResync) {
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: seq }),
+ )
+ } else {
+ expect(postMessage).not.toHaveBeenCalled()
+ }
+ })
+
+ it.each([
+ { name: "a newer sequence", overrides: { clineMessagesSeq: 3 } },
+ { name: "a newer ID and sequence", overrides: { snapshotId: "newer", clineMessagesSeq: 3 } },
+ ])("restarts after an end with $name", ({ overrides }) => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ endSnapshot(overrides)
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }),
+ )
+ expect(vi.getTimerCount()).toBe(1)
+ })
+
+ it.each([
+ { name: "an older sequence", overrides: { clineMessagesSeq: 1 } },
+ { name: "a different ID at the same sequence", overrides: { snapshotId: "other" } },
+ ])("ignores an end with $name and preserves the active snapshot", ({ overrides }) => {
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ endSnapshot(overrides)
+ appendSnapshotChunk()
+ endSnapshot()
+ })
+
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [makeMessage(2, "snapshot")],
+ clineMessagesSeq: 2,
+ })
+ })
+
+ it.each([
+ { name: "a mismatched declared total", overrides: { snapshotTotal: 2 } },
+ { name: "an incomplete message list", overrides: {} },
+ ])("rejects a snapshot end with $name", ({ name, overrides }) => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 })
+
+ act(() => {
+ startSnapshot()
+ if (name === "a mismatched declared total") {
+ appendSnapshotChunk()
+ }
+ endSnapshot(overrides)
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }),
+ )
+ expect(vi.getTimerCount()).toBe(1)
+ })
+
+ it("reconstructs a snapshot and applies contiguous append and update deltas", () => {
+ render(
+
+
+ ,
+ )
+
+ const first = makeMessage(1, "first")
+ const second = makeMessage(2, "second")
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 4,
+ snapshotId: "snapshot-1",
+ snapshotTotal: 1,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotChunk",
+ taskId: "task-1",
+ clineMessagesSeq: 4,
+ snapshotId: "snapshot-1",
+ snapshotStartIndex: 0,
+ clineMessages: [first],
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotEnd",
+ taskId: "task-1",
+ clineMessagesSeq: 4,
+ snapshotId: "snapshot-1",
+ snapshotTotal: 1,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ clineMessagesSeq: 5,
+ clineMessage: second,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessageUpdated",
+ taskId: "task-1",
+ clineMessagesSeq: 6,
+ clineMessage: { ...second, text: "updated" },
+ })
+ })
+
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [first, { ...second, text: "updated" }],
+ clineMessagesSeq: 6,
+ })
+ })
+
+ it("ignores transcript fields in generic state and clears transport state on task switch", () => {
+ const existing = makeMessage(1, "existing")
+ render(
+
+
+ ,
+ )
+
+ act(() => {
+ dispatchExtensionMessage({
+ type: "state",
+ state: { clineMessages: [makeMessage(2, "stale")], clineMessagesSeq: 99 },
+ })
+ })
+ expect(readTranscript().clineMessages).toEqual([existing])
+ expect(readTranscript().clineMessagesSeq).toBe(3)
+
+ act(() => {
+ dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } })
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ clineMessagesSeq: 4,
+ clineMessage: makeMessage(3, "wrong task"),
+ })
+ })
+
+ expect(readTranscriptFields()).toEqual({ currentTaskId: "task-2", clineMessages: [], clineMessagesSeq: 0 })
+ })
+
+ it("clears task-scoped state for a JSON-round-tripped authoritative no-task transition", () => {
+ const existing = makeMessage(1, "existing")
+ const currentTaskItem = {
+ id: "task-1",
+ number: 1,
+ ts: 1,
+ task: "Existing task",
+ tokensIn: 0,
+ tokensOut: 0,
+ totalCost: 0,
+ }
+ renderTranscript({
+ clineMessages: [existing],
+ clineMessagesSeq: 3,
+ currentTaskItem,
+ currentTaskTodos: [{ id: "todo-1", content: "Existing todo", status: "in_progress" }],
+ messageQueue: [{ id: "queued-1", timestamp: 1, text: "Queued message" }],
+ })
+
+ act(() => {
+ dispatchExtensionMessage({ type: "currentCheckpointUpdated", text: "checkpoint-1" })
+ const clearState = JSON.parse(JSON.stringify({ currentTaskId: null })) as Partial
+ dispatchExtensionMessage({ type: "state", state: clearState })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ clineMessagesSeq: 0,
+ snapshotId: "no-task-snapshot",
+ snapshotTotal: 0,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotEnd",
+ clineMessagesSeq: 0,
+ snapshotId: "no-task-snapshot",
+ snapshotTotal: 0,
+ })
+ })
+
+ expect(readTranscript()).toEqual({
+ currentTaskId: null,
+ currentTaskInstanceId: null,
+ currentTaskItem: null,
+ currentTaskTodos: [],
+ messageQueue: [],
+ currentCheckpoint: null,
+ clineMessages: [],
+ clineMessagesSeq: 0,
+ })
+ })
+
+ it("does not retain a pending transcript when the authoritative state clears the task", () => {
+ vi.useFakeTimers()
+ const postMessage = renderTranscriptWithPostMessageSpy({
+ clineMessages: [makeMessage(1, "existing")],
+ clineMessagesSeq: 1,
+ })
+
+ act(() => {
+ startSnapshot({ clineMessagesSeq: 2, snapshotId: "pending" })
+ dispatchExtensionMessage({ type: "state", state: { currentTaskId: null } })
+ appendSnapshotChunk({ taskId: undefined, clineMessagesSeq: 2, snapshotId: "pending" })
+ })
+
+ expect(vi.getTimerCount()).toBe(0)
+ act(() => vi.advanceTimersByTime(30_000))
+ expect(postMessage).not.toHaveBeenCalled()
+ expect(readTranscript()).toEqual({
+ currentTaskId: null,
+ currentTaskInstanceId: null,
+ currentTaskItem: null,
+ currentTaskTodos: [],
+ messageQueue: [],
+ currentCheckpoint: null,
+ clineMessages: [],
+ clineMessagesSeq: 0,
+ })
+ })
+
+ it("preserves task-scoped state when a partial state update omits currentTaskId", () => {
+ const existing = makeMessage(1, "existing")
+ const currentTaskItem = {
+ id: "task-1",
+ number: 1,
+ ts: 1,
+ task: "Existing task",
+ tokensIn: 0,
+ tokensOut: 0,
+ totalCost: 0,
+ }
+ const currentTaskTodos = [{ id: "todo-1", content: "Existing todo", status: "pending" as const }]
+ const messageQueue = [{ id: "queued-1", timestamp: 1, text: "Queued message" }]
+ renderTranscript({
+ clineMessages: [existing],
+ clineMessagesSeq: 3,
+ currentTaskItem,
+ currentTaskTodos,
+ messageQueue,
+ })
+
+ act(() => {
+ dispatchExtensionMessage({ type: "currentCheckpointUpdated", text: "checkpoint-1" })
+ dispatchExtensionMessage({ type: "state", state: { version: "2.0.0" } })
+ })
+
+ expect(readTranscript()).toEqual({
+ currentTaskId: "task-1",
+ currentTaskItem,
+ currentTaskTodos,
+ messageQueue,
+ currentCheckpoint: "checkpoint-1",
+ clineMessages: [existing],
+ clineMessagesSeq: 3,
+ })
+ })
+
+ it("requests one resync when a delta sequence has a gap", () => {
+ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined)
+ try {
+ render(
+
+
+ ,
+ )
+ postMessage.mockClear() // Ignore webviewDidLaunch.
+
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ clineMessagesSeq: 3,
+ clineMessage: makeMessage(3, "gap"),
+ })
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ clineMessagesSeq: 4,
+ clineMessage: makeMessage(4, "another gap"),
+ })
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith({
+ type: "requestClineMessagesResync",
+ taskId: "task-1",
+ expectedSeq: 2,
+ receivedSeq: 3,
+ })
+ } finally {
+ postMessage.mockRestore()
+ }
+ })
+
+ it("retires a failed resync and recovers from a replacement snapshot", () => {
+ const first = makeMessage(1, "first")
+ const recovered = makeMessage(2, "recovered")
+ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined)
+ try {
+ renderTranscript({ clineMessages: [first], clineMessagesSeq: 1 })
+ postMessage.mockClear()
+
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ clineMessagesSeq: 3,
+ clineMessage: makeMessage(3, "gap"),
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 3,
+ snapshotId: "invalid-snapshot",
+ snapshotTotal: 2,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotChunk",
+ taskId: "task-1",
+ clineMessagesSeq: 3,
+ snapshotId: "invalid-snapshot",
+ snapshotStartIndex: 1,
+ clineMessages: [first],
+ })
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(2)
+ expect(postMessage).toHaveBeenLastCalledWith({
+ type: "requestClineMessagesResync",
+ taskId: "task-1",
+ expectedSeq: 2,
+ receivedSeq: 3,
+ })
+
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 3,
+ snapshotId: "replacement-snapshot",
+ snapshotTotal: 2,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotChunk",
+ taskId: "task-1",
+ clineMessagesSeq: 3,
+ snapshotId: "replacement-snapshot",
+ snapshotStartIndex: 0,
+ clineMessages: [first, recovered],
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotEnd",
+ taskId: "task-1",
+ clineMessagesSeq: 3,
+ snapshotId: "replacement-snapshot",
+ snapshotTotal: 2,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId: "task-1",
+ clineMessagesSeq: 4,
+ clineMessage: makeMessage(4, "after recovery"),
+ })
+ })
+
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [first, recovered, makeMessage(4, "after recovery")],
+ clineMessagesSeq: 4,
+ })
+ } finally {
+ postMessage.mockRestore()
+ }
+ })
+
+ it("allows another resync when a response is lost", async () => {
+ vi.useFakeTimers()
+ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined)
+ try {
+ renderTranscript({ clineMessagesSeq: 1 })
+ postMessage.mockClear()
+
+ act(() => {
+ appendClineMessage(makeMessage(3, "gap"), 3, "task-1")
+ appendClineMessage(makeMessage(4, "suppressed while pending"), 4, "task-1")
+ })
+ expect(postMessage).toHaveBeenCalledTimes(1)
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5_000)
+ })
+ act(() => appendClineMessage(makeMessage(5, "retry"), 5, "task-1"))
+
+ expect(postMessage).toHaveBeenCalledTimes(2)
+ expect(postMessage).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ type: "requestClineMessagesResync",
+ expectedSeq: 2,
+ receivedSeq: 5,
+ }),
+ )
+ } finally {
+ postMessage.mockRestore()
+ vi.useRealTimers()
+ }
+ })
+
+ it("rejects malformed deltas and updates to unknown messages", () => {
+ const first = makeMessage(1, "first")
+ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined)
+ try {
+ renderTranscript({ clineMessages: [first], clineMessagesSeq: 1 })
+ postMessage.mockClear()
+
+ act(() => {
+ dispatchExtensionMessage({ type: "clineMessageAppended", taskId: "task-1" })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 1,
+ snapshotId: "same-sequence",
+ snapshotTotal: 1,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotChunk",
+ taskId: "task-1",
+ clineMessagesSeq: 1,
+ snapshotId: "same-sequence",
+ snapshotStartIndex: 0,
+ clineMessages: [first],
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotEnd",
+ taskId: "task-1",
+ clineMessagesSeq: 1,
+ snapshotId: "same-sequence",
+ snapshotTotal: 1,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessageUpdated",
+ taskId: "task-1",
+ clineMessagesSeq: 2,
+ clineMessage: makeMessage(99, "unknown"),
+ })
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(2)
+ expect(postMessage).toHaveBeenLastCalledWith(
+ expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }),
+ )
+ expect(readTranscript().clineMessages).toEqual([first])
+ } finally {
+ postMessage.mockRestore()
+ }
+ })
+
+ it("ignores covered and stale deltas but restarts after a newer delta interleaves", () => {
+ const first = makeMessage(1, "first")
+ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined)
+ try {
+ renderTranscript({ clineMessages: [first], clineMessagesSeq: 1 })
+ postMessage.mockClear()
+
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 4,
+ snapshotId: "in-flight",
+ snapshotTotal: 1,
+ })
+ appendClineMessage(makeMessage(4, "already covered"), 4, "task-1")
+ appendClineMessage(makeMessage(5, "interleaved"), 5, "task-1")
+ appendClineMessage(makeMessage(1, "stale"), 1, "task-1")
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(1)
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 5 }),
+ )
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [first],
+ clineMessagesSeq: 1,
+ })
+ } finally {
+ postMessage.mockRestore()
+ }
+ })
+
+ it("validates snapshot starts and ignores stale or duplicate starts", () => {
+ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined)
+ try {
+ renderTranscript({ clineMessagesSeq: 1 })
+ postMessage.mockClear()
+
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "other-task",
+ clineMessagesSeq: 2,
+ snapshotId: "wrong-task",
+ snapshotTotal: 0,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: -1,
+ snapshotId: "invalid-sequence",
+ snapshotTotal: 0,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 1,
+ snapshotId: "stale",
+ snapshotTotal: 0,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 4,
+ snapshotId: "newest",
+ snapshotTotal: 0,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 4,
+ snapshotId: "newest",
+ snapshotTotal: 0,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 3,
+ snapshotId: "older-active",
+ snapshotTotal: 0,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 5,
+ snapshotId: "",
+ snapshotTotal: -1,
+ })
+ })
+
+ expect(postMessage).toHaveBeenCalledTimes(2)
+ expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([-1, 5])
+ } finally {
+ postMessage.mockRestore()
+ }
+ })
+
+ it("rejects missing, mismatched, and incomplete snapshot chunks and endings", () => {
+ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined)
+ try {
+ renderTranscript({ clineMessagesSeq: 1 })
+ postMessage.mockClear()
+
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotChunk",
+ taskId: "other-task",
+ clineMessagesSeq: 2,
+ snapshotId: "ignored",
+ snapshotStartIndex: 0,
+ clineMessages: [makeMessage(1, "ignored")],
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotChunk",
+ taskId: "task-1",
+ clineMessagesSeq: 2,
+ snapshotId: "missing-start",
+ snapshotStartIndex: 0,
+ clineMessages: [makeMessage(1, "missing")],
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 3,
+ snapshotId: "chunk-check",
+ snapshotTotal: 1,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotChunk",
+ taskId: "task-1",
+ clineMessagesSeq: 4,
+ snapshotId: "newer-mismatch",
+ snapshotStartIndex: 0,
+ clineMessages: [makeMessage(1, "mismatch")],
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 5,
+ snapshotId: "bad-chunk",
+ snapshotTotal: 1,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotChunk",
+ taskId: "task-1",
+ clineMessagesSeq: 5,
+ snapshotId: "bad-chunk",
+ snapshotStartIndex: 1,
+ clineMessages: [makeMessage(1, "bad index")],
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotEnd",
+ taskId: "other-task",
+ clineMessagesSeq: 6,
+ snapshotId: "ignored-end",
+ snapshotTotal: 0,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotEnd",
+ taskId: "task-1",
+ clineMessagesSeq: 6,
+ snapshotId: "missing-end-start",
+ snapshotTotal: 0,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 7,
+ snapshotId: "incomplete",
+ snapshotTotal: 1,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotEnd",
+ taskId: "task-1",
+ clineMessagesSeq: 7,
+ snapshotId: "incomplete",
+ snapshotTotal: 1,
+ })
+ })
+
+ expect(postMessage.mock.calls).toEqual([
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 2, receivedSeq: 2 }],
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 2, receivedSeq: 4 }],
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 2, receivedSeq: 5 }],
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 2, receivedSeq: 6 }],
+ [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 2, receivedSeq: 7 }],
+ ])
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [],
+ clineMessagesSeq: 1,
+ })
+ } finally {
+ postMessage.mockRestore()
+ }
+ })
+
+ it("keeps the prior transcript when a snapshot end is dropped", () => {
+ const existing = makeMessage(1, "existing")
+ const replacement = makeMessage(2, "replacement")
+ renderTranscript({ clineMessages: [existing], clineMessagesSeq: 1 })
+
+ act(() => {
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId: "task-1",
+ clineMessagesSeq: 2,
+ snapshotId: "dropped-end",
+ snapshotTotal: 1,
+ })
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotChunk",
+ taskId: "task-1",
+ clineMessagesSeq: 2,
+ snapshotId: "dropped-end",
+ snapshotStartIndex: 0,
+ clineMessages: [replacement],
+ })
+ })
+
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [existing],
+ clineMessagesSeq: 1,
+ })
+ })
+
+ it("requests recovery for legacy unsequenced updates", () => {
+ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined)
+ try {
+ renderTranscript({ clineMessagesSeq: 2 })
+ postMessage.mockClear()
+
+ act(() => dispatchExtensionMessage({ type: "messageUpdated", clineMessagesSeq: 9 }))
+
+ expect(postMessage).toHaveBeenCalledWith({
+ type: "requestClineMessagesResync",
+ taskId: "task-1",
+ expectedSeq: 3,
+ receivedSeq: 9,
+ })
+ } finally {
+ postMessage.mockRestore()
+ }
+ })
+
+ it("hydrates metadata, non-empty transcripts, and empty transcripts through shared helpers", () => {
+ renderTranscript({ clineMessages: [makeMessage(1, "existing")], clineMessagesSeq: 1 })
+
+ act(() => {
+ hydrateExtensionState({ version: "2.0.0" })
+ })
+ expect(readTranscript().clineMessages).toEqual([makeMessage(1, "existing")])
+
+ act(() => {
+ hydrateExtensionState({
+ currentTaskId: "task-1",
+ clineMessages: [makeMessage(2, "hydrated")],
+ clineMessagesSeq: 4,
+ })
+ appendClineMessage(makeMessage(3, "appended"), 5, "task-1")
+ })
+ expect(readTranscriptFields()).toEqual({
+ currentTaskId: "task-1",
+ clineMessages: [makeMessage(2, "hydrated"), makeMessage(3, "appended")],
+ clineMessagesSeq: 5,
+ })
+
+ act(() => {
+ hydrateExtensionState({ clineMessages: [] }, { taskId: "task-1", clineMessagesSeq: 6 })
+ })
+ expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 6 })
+ })
+ })
})
describe("mergeExtensionState", () => {
@@ -471,152 +2892,4 @@ describe("mergeExtensionState", () => {
customTools: false,
})
})
-
- describe("clineMessagesSeq protection", () => {
- const baseState: ExtensionState = {
- version: "",
- mcpEnabled: false,
- clineMessages: [],
- taskHistory: [],
- shouldShowAnnouncement: false,
- enableCheckpoints: true,
- writeDelayMs: 1000,
- mode: "default",
- experiments: {} as Record,
- customModes: [],
- maxOpenTabsContext: 20,
- maxWorkspaceFiles: 100,
- apiConfiguration: {},
- telemetrySetting: "unset",
- showRooIgnoredFiles: true,
- enableSubfolderRules: false,
- renderContext: "sidebar",
- cloudUserInfo: null,
- organizationAllowList: { allowAll: true, providers: {} },
- autoCondenseContext: true,
- autoCondenseContextPercent: 100,
- cloudIsAuthenticated: false,
- sharingEnabled: false,
- publicSharingEnabled: false,
- profileThresholds: {},
- hasOpenedModeSelector: false,
- maxImageFileSize: 5,
- maxTotalImageSize: 20,
- taskSyncEnabled: false,
- checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
- maxReadFileLine: -1,
- diffFuzzyThreshold: DEFAULT_DIFF_FUZZY_THRESHOLD,
- }
-
- const makeMessage = (ts: number, text: string): ClineMessage =>
- ({ ts, type: "say", say: "text", text }) as ClineMessage
-
- it("rejects stale clineMessages when seq is not newer", () => {
- const newerMessages = [makeMessage(1, "hello"), makeMessage(2, "world")]
- const staleMessages = [makeMessage(1, "hello")]
-
- const prevState: ExtensionState = {
- ...baseState,
- clineMessages: newerMessages,
- clineMessagesSeq: 5,
- }
-
- const result = mergeExtensionState(prevState, {
- clineMessages: staleMessages,
- clineMessagesSeq: 3, // stale seq
- })
-
- // Should keep the newer messages
- expect(result.clineMessages).toBe(newerMessages)
- expect(result.clineMessagesSeq).toBe(5)
- })
-
- it("rejects clineMessages when seq equals current (not strictly greater)", () => {
- const currentMessages = [makeMessage(1, "hello"), makeMessage(2, "world")]
- const sameSeqMessages = [makeMessage(1, "hello")]
-
- const prevState: ExtensionState = {
- ...baseState,
- clineMessages: currentMessages,
- clineMessagesSeq: 5,
- }
-
- const result = mergeExtensionState(prevState, {
- clineMessages: sameSeqMessages,
- clineMessagesSeq: 5, // same seq, not strictly greater
- })
-
- expect(result.clineMessages).toBe(currentMessages)
- expect(result.clineMessagesSeq).toBe(5)
- })
-
- it("accepts clineMessages when seq is strictly greater", () => {
- const oldMessages = [makeMessage(1, "hello")]
- const newMessages = [makeMessage(1, "hello"), makeMessage(2, "world")]
-
- const prevState: ExtensionState = {
- ...baseState,
- clineMessages: oldMessages,
- clineMessagesSeq: 3,
- }
-
- const result = mergeExtensionState(prevState, {
- clineMessages: newMessages,
- clineMessagesSeq: 4, // newer seq
- })
-
- expect(result.clineMessages).toBe(newMessages)
- expect(result.clineMessagesSeq).toBe(4)
- })
-
- it("preserves clineMessages when newState does not include them (cloud event path)", () => {
- const existingMessages = [makeMessage(1, "hello"), makeMessage(2, "world")]
-
- const prevState: ExtensionState = {
- ...baseState,
- clineMessages: existingMessages,
- clineMessagesSeq: 5,
- }
-
- // Simulate a cloud event push that omits clineMessages and clineMessagesSeq
- const result = mergeExtensionState(prevState, {
- cloudIsAuthenticated: true,
- })
-
- expect(result.clineMessages).toBe(existingMessages)
- expect(result.clineMessagesSeq).toBe(5)
- })
-
- it("applies clineMessages normally when neither state has seq (backward compat)", () => {
- const oldMessages = [makeMessage(1, "hello")]
- const newMessages = [makeMessage(1, "hello"), makeMessage(2, "world")]
-
- const prevState: ExtensionState = {
- ...baseState,
- clineMessages: oldMessages,
- }
-
- const result = mergeExtensionState(prevState, {
- clineMessages: newMessages,
- })
-
- expect(result.clineMessages).toBe(newMessages)
- })
-
- it("applies clineMessages when prevState has no seq but newState does (first push)", () => {
- const prevState: ExtensionState = {
- ...baseState,
- clineMessages: [],
- }
-
- const newMessages = [makeMessage(1, "hello")]
- const result = mergeExtensionState(prevState, {
- clineMessages: newMessages,
- clineMessagesSeq: 1,
- })
-
- expect(result.clineMessages).toBe(newMessages)
- expect(result.clineMessagesSeq).toBe(1)
- })
- })
})
diff --git a/webview-ui/src/utils/test-utils.tsx b/webview-ui/src/utils/test-utils.tsx
index 847c401f2c..617e18a1ea 100644
--- a/webview-ui/src/utils/test-utils.tsx
+++ b/webview-ui/src/utils/test-utils.tsx
@@ -3,7 +3,7 @@ import { render as rtlRender, type RenderOptions } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { vi, type Mock } from "vitest"
-import type { ExtensionState } from "@roo-code/types"
+import type { ClineMessage, ExtensionMessage, ExtensionState } from "@roo-code/types"
import { TooltipProvider } from "@src/components/ui/tooltip"
import { STANDARD_TOOLTIP_DELAY } from "@src/components/ui/standard-tooltip"
@@ -37,6 +37,67 @@ export const makeExtensionState = (overrides: Partial = {}): Par
...overrides,
})
+let nextTranscriptSnapshotId = 0
+
+export const dispatchExtensionMessage = (message: ExtensionMessage) => {
+ window.dispatchEvent(new MessageEvent("message", { data: message }))
+}
+
+export const hydrateExtensionState = (
+ state: Partial,
+ options: { taskId?: string; clineMessagesSeq?: number } = {},
+) => {
+ const { clineMessages, clineMessagesSeq: stateSeq, ...metadataState } = state
+ const taskId = options.taskId ?? metadataState.currentTaskId ?? undefined
+ const clineMessagesSeq = options.clineMessagesSeq ?? stateSeq ?? 0
+
+ dispatchExtensionMessage({
+ type: "state",
+ state: metadataState,
+ })
+
+ if (clineMessages === undefined) {
+ return
+ }
+
+ const snapshotId = `test-transcript-${++nextTranscriptSnapshotId}`
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotStart",
+ taskId,
+ clineMessagesSeq,
+ snapshotId,
+ snapshotTotal: clineMessages.length,
+ })
+
+ if (clineMessages.length > 0) {
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotChunk",
+ taskId,
+ clineMessagesSeq,
+ snapshotId,
+ snapshotStartIndex: 0,
+ clineMessages,
+ })
+ }
+
+ dispatchExtensionMessage({
+ type: "clineMessagesSnapshotEnd",
+ taskId,
+ clineMessagesSeq,
+ snapshotId,
+ snapshotTotal: clineMessages.length,
+ })
+}
+
+export const appendClineMessage = (clineMessage: ClineMessage, clineMessagesSeq: number, taskId?: string) => {
+ dispatchExtensionMessage({
+ type: "clineMessageAppended",
+ taskId,
+ clineMessagesSeq,
+ clineMessage,
+ })
+}
+
export function mockVscodePostMessage(existing?: Mock) {
const postMessage = existing ?? vi.fn()