Skip to content

Commit 3a957ee

Browse files
committed
fix(webview): capture transcript snapshots before queueing
1 parent a117660 commit 3a957ee

2 files changed

Lines changed: 98 additions & 22 deletions

File tree

src/core/webview/ClineProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1575,6 +1575,8 @@ export class ClineProvider
15751575
? this.bumpClineMessagesSeq(taskId)
15761576
: this.getClineMessagesSeq(taskId)
15771577
: 0
1578+
// Capture the payload with its sequence so later deltas cannot leak into this snapshot.
1579+
const messages = structuredClone(currentTask?.clineMessages ?? [])
15781580
const snapshotId = `${taskId ?? "none"}:${++this.nextClineMessagesSnapshotId}`
15791581
const generation = options.generation ?? this.clineMessagesTransportGeneration
15801582

@@ -1585,7 +1587,6 @@ export class ClineProvider
15851587
if (!isCurrent()) {
15861588
return
15871589
}
1588-
const messages = structuredClone(currentTask?.clineMessages ?? [])
15891590

15901591
await this.postMessageToWebview({
15911592
type: "clineMessagesSnapshotStart",

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 96 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,80 @@ describe("ClineProvider", () => {
10601060
expect(new Set(posts.map(({ snapshotId }) => snapshotId)).size).toBe(1)
10611061
})
10621062

1063+
test.each([false, true])(
1064+
"captures snapshot payload with its sequence before queued deltas (bumpSeq=%s)",
1065+
async (bumpSeq) => {
1066+
const messages = Array.from(
1067+
{ length: 200 },
1068+
(_, index): ClineMessage => ({
1069+
ts: index + 1,
1070+
type: "say",
1071+
say: "text",
1072+
text: `message ${index + 1}`,
1073+
images: ["original-image"],
1074+
}),
1075+
)
1076+
const task = { taskId: "task-1", clineMessages: messages }
1077+
setCurrentTask(task)
1078+
provider["clineMessagesSeqByTaskId"].set(task.taskId, 4)
1079+
const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined)
1080+
const expectedSnapshot = structuredClone(messages)
1081+
let releaseQueue!: () => void
1082+
provider["clineMessagesPostQueue"] = new Promise<void>((resolve) => {
1083+
releaseQueue = resolve
1084+
})
1085+
1086+
const snapshot = provider.postClineMessagesSnapshot(task.taskId, { bumpSeq })
1087+
const appended: ClineMessage = { ts: 201, type: "say", say: "text", text: "appended after snapshot" }
1088+
task.clineMessages.push(appended)
1089+
const append = provider.postClineMessageAppended(task.taskId, appended)
1090+
messages[0].text = "updated after snapshot"
1091+
messages[0].images?.push("updated-image")
1092+
const update = provider.postClineMessageUpdated(task.taskId, messages[0])
1093+
releaseQueue()
1094+
await Promise.all([snapshot, append, update])
1095+
1096+
const snapshotSeq = bumpSeq ? 5 : 4
1097+
const snapshotId = "task-1:1"
1098+
expect(postSpy.mock.calls.map(([message]) => message)).toEqual([
1099+
{
1100+
type: "clineMessagesSnapshotStart",
1101+
taskId: task.taskId,
1102+
clineMessagesSeq: snapshotSeq,
1103+
snapshotId,
1104+
snapshotTotal: 200,
1105+
},
1106+
{
1107+
type: "clineMessagesSnapshotChunk",
1108+
taskId: task.taskId,
1109+
clineMessagesSeq: snapshotSeq,
1110+
snapshotId,
1111+
snapshotStartIndex: 0,
1112+
clineMessages: expectedSnapshot,
1113+
},
1114+
{
1115+
type: "clineMessagesSnapshotEnd",
1116+
taskId: task.taskId,
1117+
clineMessagesSeq: snapshotSeq,
1118+
snapshotId,
1119+
snapshotTotal: 200,
1120+
},
1121+
{
1122+
type: "clineMessageAppended",
1123+
taskId: task.taskId,
1124+
clineMessagesSeq: snapshotSeq + 1,
1125+
clineMessage: appended,
1126+
},
1127+
{
1128+
type: "clineMessageUpdated",
1129+
taskId: task.taskId,
1130+
clineMessagesSeq: snapshotSeq + 2,
1131+
clineMessage: messages[0],
1132+
},
1133+
])
1134+
},
1135+
)
1136+
10631137
test.each([
10641138
["append", "clineMessageAppended"],
10651139
["update", "clineMessageUpdated"],
@@ -1215,33 +1289,34 @@ describe("ClineProvider", () => {
12151289
)
12161290
})
12171291

1218-
test("drops a snapshot invalidated before its first post without cloning it", async () => {
1219-
const task = {
1220-
taskId: "task-1",
1221-
clineMessages: [{ ts: 1, type: "say", say: "text", text: "message" }] as ClineMessage[],
1222-
}
1223-
setCurrentTask(task)
1224-
const postSpy = vi.spyOn(provider, "postMessageToWebview")
1225-
let releaseQueue!: () => void
1226-
Object.assign(provider, {
1227-
clineMessagesPostQueue: new Promise<void>((resolve) => {
1228-
releaseQueue = resolve
1229-
}),
1230-
})
1292+
test.each(["focus", "generation"] as const)(
1293+
"drops a snapshot when %s changes before its first post",
1294+
async (change) => {
1295+
const task = {
1296+
taskId: "task-1",
1297+
clineMessages: [{ ts: 1, type: "say", say: "text", text: "message" }] as ClineMessage[],
1298+
}
1299+
setCurrentTask(task)
1300+
const postSpy = vi.spyOn(provider, "postMessageToWebview")
1301+
let releaseQueue!: () => void
1302+
Object.assign(provider, {
1303+
clineMessagesPostQueue: new Promise<void>((resolve) => {
1304+
releaseQueue = resolve
1305+
}),
1306+
})
12311307

1232-
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone")
1233-
try {
12341308
const snapshot = provider.postClineMessagesSnapshot("task-1")
1235-
task.taskId = "task-2"
1309+
if (change === "focus") {
1310+
task.taskId = "task-2"
1311+
} else {
1312+
provider["clineMessagesTransportGeneration"]++
1313+
}
12361314
releaseQueue()
12371315
await snapshot
12381316

12391317
expect(postSpy).not.toHaveBeenCalled()
1240-
expect(structuredCloneSpy).not.toHaveBeenCalled()
1241-
} finally {
1242-
structuredCloneSpy.mockRestore()
1243-
}
1244-
})
1318+
},
1319+
)
12451320

12461321
test("uses monotonic task-scoped snapshot IDs and an empty no-task snapshot", async () => {
12471322
await provider.resolveWebviewView(mockWebviewView)

0 commit comments

Comments
 (0)