Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 1e858aa

Browse files
authored
fix(cloud): keep resumed messages and progress in sync (#3914)
1 parent cc1c282 commit 1e858aa

10 files changed

Lines changed: 241 additions & 21 deletions

packages/core/src/cloud-task/cloud-task-engine.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1114,7 +1114,9 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
11141114
watcher.totalEntryCount = watcher.resumeFromEntryCount;
11151115
watcher.hasEmittedSnapshot = true;
11161116
watcher.isBootstrapping = false;
1117-
void this.connectSse(key, { startLatest: true });
1117+
// The renderer dedupes this leaf replay against its hydrated resume chain; starting at latest
1118+
// can lose entries persisted between that hydration request and the stream connection.
1119+
void this.connectSse(key);
11181120
return;
11191121
}
11201122

packages/core/src/cloud-task/cloud-task.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,62 @@ describe("CloudTaskEngine", () => {
352352
);
353353
});
354354

355+
it("replays a resumed run stream so hydration cannot miss its live tail", async () => {
356+
const updates: unknown[] = [];
357+
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));
358+
359+
mockNetFetch.mockResolvedValueOnce(
360+
createJsonResponse({
361+
id: "run-1",
362+
status: "in_progress",
363+
stage: "build",
364+
output: null,
365+
error_message: null,
366+
branch: "main",
367+
updated_at: "2026-01-01T00:00:00Z",
368+
}),
369+
);
370+
mockStreamFetch.mockResolvedValueOnce(
371+
createOpenSseResponse(
372+
'id: 1\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:01Z","notification":{"jsonrpc":"2.0","method":"_posthog/progress","params":{"id":"sandbox","status":"completed","title":"Restored sandbox"}}}\n\n',
373+
),
374+
);
375+
376+
service.watch({
377+
taskId: "task-1",
378+
runId: "run-1",
379+
apiHost: "https://app.example.com",
380+
teamId: 2,
381+
resumeFromEntryCount: 3,
382+
});
383+
384+
await waitFor(() =>
385+
updates.some((update) => {
386+
const payload = update as {
387+
kind?: string;
388+
totalEntryCount?: number;
389+
};
390+
return payload.kind === "logs" && payload.totalEntryCount === 4;
391+
}),
392+
);
393+
394+
expect(mockStreamFetch).toHaveBeenCalledWith(
395+
"https://app.example.com/api/projects/2/tasks/task-1/runs/run-1/stream/",
396+
expect.objectContaining({
397+
headers: expect.objectContaining({
398+
Authorization: "Bearer token",
399+
Accept: "text/event-stream",
400+
}),
401+
}),
402+
);
403+
expect(
404+
mockNetFetch.mock.calls.some(([input]) => {
405+
const url = typeof input === "string" ? input : input.toString();
406+
return url.includes("/session_logs/");
407+
}),
408+
).toBe(false);
409+
});
410+
355411
it("drops a re-delivered log entry with a duplicate stream id", async () => {
356412
const updates: unknown[] = [];
357413
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));

packages/core/src/sessions/sessionEvents.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
convertStoredEntriesToEvents,
99
extractUserPromptsFromEvents,
1010
hasSessionPromptEvent,
11+
hasSessionPromptEventForTaskRun,
1112
isAbsoluteFolderPath,
1213
isFatalSessionError,
1314
promptReferencesAbsoluteFolder,
@@ -196,6 +197,37 @@ describe("hasSessionPromptEvent", () => {
196197
expect(hasSessionPromptEvent([notification])).toBe(false);
197198
expect(hasSessionPromptEvent([])).toBe(false);
198199
});
200+
201+
it("does not attribute an ancestor prompt to a resumed run", () => {
202+
const storedEntry = (event: AcpMessage): StoredLogEntry => ({
203+
type: "notification",
204+
timestamp: new Date(event.ts).toISOString(),
205+
notification: event.message,
206+
});
207+
const leafPrompt = {
208+
...promptRequest,
209+
ts: 3,
210+
message: { ...promptRequest.message, id: 2 },
211+
};
212+
const events = convertStoredEntriesToEvents(
213+
[
214+
storedEntry(promptRequest),
215+
storedEntry(notification),
216+
storedEntry(leafPrompt),
217+
],
218+
undefined,
219+
{
220+
taskRunId: "resume-run",
221+
startEntryIndex: 0,
222+
firstPositionedEntryIndex: 2,
223+
},
224+
);
225+
226+
expect(
227+
hasSessionPromptEventForTaskRun(events.slice(0, 2), "resume-run"),
228+
).toBe(false);
229+
expect(hasSessionPromptEventForTaskRun(events, "resume-run")).toBe(true);
230+
});
199231
});
200232

201233
describe("convertStoredEntriesToEvents — imported user prompts", () => {

packages/core/src/sessions/sessionEvents.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,18 @@ export function hasSessionPromptEvent(events: AcpMessage[]): boolean {
427427
);
428428
}
429429

430+
export function hasSessionPromptEventForTaskRun(
431+
events: AcpMessage[],
432+
taskRunId: string,
433+
): boolean {
434+
return events.some(
435+
(event) =>
436+
isJsonRpcRequest(event.message) &&
437+
event.message.method === "session/prompt" &&
438+
getStoredLogEventPosition(event)?.taskRunId === taskRunId,
439+
);
440+
}
441+
430442
/**
431443
* Whether an event is a turn-complete notification.
432444
*/

packages/core/src/sessions/sessionService.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import {
8989
getStoredLogEventPosition,
9090
getUserShellExecutesSinceLastPrompt,
9191
hasSessionPromptEvent,
92+
hasSessionPromptEventForTaskRun,
9293
isTurnCompleteEvent,
9394
normalizePromptToBlocks,
9495
promptReferencesAbsoluteFolder,
@@ -6222,27 +6223,34 @@ export class SessionService {
62226223
);
62236224
}
62246225
}
6225-
const hasUserPrompt = events.some(
6226-
(e: AcpMessage) =>
6227-
isJsonRpcRequest(e.message) && e.message.method === "session/prompt",
6226+
const hasCurrentRunUserPrompt = isResumeRun
6227+
? hasSessionPromptEventForTaskRun(events, taskRunId)
6228+
: hasSessionPromptEvent(events);
6229+
6230+
// A reload loses the in-memory placeholder; restore it from the resume
6231+
// state or initial task description until the active run records its prompt.
6232+
const seedContent = isResumeRun
6233+
? typeof runState?.pending_user_message === "string"
6234+
? runState.pending_user_message
6235+
: undefined
6236+
: (this.initialCloudOptimisticPrompt.get(taskId) ?? taskDescription);
6237+
const hasOptimisticUserPrompt = session.optimisticItems.some(
6238+
(item) => item.type === "user_message",
62286239
);
6229-
6230-
// Seed the optimistic user-message bubble whenever the agent has
6231-
// not yet recorded an initial `session/prompt` request — covers the
6232-
// brand-new task case as well as "agent has emitted lifecycle
6233-
// notifications but hasn't received its first prompt yet". Prefer the
6234-
// stashed initial prompt (which carries the channel CONTEXT.md block, so
6235-
// its chip renders right away) over the bare task description.
6236-
const seedContent =
6237-
this.initialCloudOptimisticPrompt.get(taskId) ?? taskDescription;
6238-
if (!isTerminalRun && !hasUserPrompt && seedContent?.trim()) {
6240+
if (
6241+
!isTerminalRun &&
6242+
!hasCurrentRunUserPrompt &&
6243+
!hasOptimisticUserPrompt &&
6244+
seedContent?.trim()
6245+
) {
62396246
this.d.store.appendOptimisticItem(taskRunId, {
62406247
type: "user_message",
62416248
content: seedContent,
62426249
timestamp: Date.now(),
6250+
...(isResumeRun ? { pinToTop: false } : {}),
62436251
});
62446252
}
6245-
if (hasUserPrompt || isTerminalRun) {
6253+
if (hasCurrentRunUserPrompt || isTerminalRun) {
62466254
// The stash is no longer needed once the real prompt lands - and a
62476255
// finished run gets no further echoes, so leftover optimistic items
62486256
// would otherwise linger as phantom tail items on the final transcript.

packages/ui/src/features/sessions/components/buildConversationItems.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export interface BuildResult {
8787
interface ProgressCardState {
8888
/** Step key → full step entry. Key order reflects arrival order. */
8989
steps: Map<string, Step>;
90-
/** Reference to the pushed render item; mutated in place as events arrive. */
90+
/** Replaced when steps change so memoized rows observe live progress. */
9191
renderItem: {
9292
sessionUpdate: "progress_group";
9393
steps: Step[];
@@ -818,8 +818,20 @@ function syncProgressCard(
818818
? { ...step, status: "in_progress" as StepStatus }
819819
: step,
820820
);
821-
card.renderItem.steps = ordered;
822-
card.renderItem.isActive = ordered.some((s) => s.status === "in_progress");
821+
const renderItem = {
822+
sessionUpdate: "progress_group" as const,
823+
steps: ordered,
824+
isActive: ordered.some((step) => step.status === "in_progress"),
825+
};
826+
card.renderItem = renderItem;
827+
828+
const item = b.items[card.itemIndex];
829+
if (
830+
item?.type === "session_update" &&
831+
item.update.sessionUpdate === "progress_group"
832+
) {
833+
b.items[card.itemIndex] = { ...item, update: renderItem };
834+
}
823835
}
824836

825837
function handleProgress(

packages/ui/src/features/sessions/components/incrementalConversationItems.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,52 @@ describe("createIncrementalConversationBuilder", () => {
364364
);
365365
});
366366

367+
it("replaces a pre-prompt progress row when it completes after the prompt starts", () => {
368+
const inc = createIncrementalConversationBuilder();
369+
const events = [
370+
progressMsg(
371+
1,
372+
"sandbox",
373+
"in_progress",
374+
"Restoring sandbox",
375+
"setup:run-1",
376+
),
377+
userPromptMsg(2, 1, "continue"),
378+
progressMsg(3, "sandbox", "completed", "Restored sandbox", "setup:run-1"),
379+
];
380+
381+
const before = inc.update(events.slice(0, 2), true);
382+
const beforeProgress = before.items.find(
383+
(item) =>
384+
item.type === "session_update" &&
385+
item.update.sessionUpdate === "progress_group",
386+
);
387+
388+
const after = inc.update(events, true);
389+
const afterProgress = after.items.find(
390+
(item) =>
391+
item.type === "session_update" &&
392+
item.update.sessionUpdate === "progress_group",
393+
);
394+
395+
expect(afterProgress).not.toBe(beforeProgress);
396+
expect(
397+
afterProgress?.type === "session_update" &&
398+
afterProgress.update.sessionUpdate === "progress_group"
399+
? afterProgress.update
400+
: null,
401+
).toMatchObject({
402+
isActive: false,
403+
steps: [
404+
{
405+
key: "sandbox",
406+
label: "Restored sandbox",
407+
status: "completed",
408+
},
409+
],
410+
});
411+
});
412+
367413
it("keeps completed-turn item references stable while the active turn streams", () => {
368414
const inc = createIncrementalConversationBuilder();
369415
const base = [

packages/ui/src/features/sessions/components/mergeConversationItems.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
22
import type { ConversationItem } from "./buildConversationItems";
33
import { mergeConversationItems } from "./mergeConversationItems";
44

5+
function progressGroup(id: string): ConversationItem {
6+
return {
7+
type: "session_update",
8+
id,
9+
update: {
10+
sessionUpdate: "progress_group",
11+
steps: [],
12+
isActive: true,
13+
},
14+
turnContext: {
15+
toolCalls: new Map(),
16+
childItems: new Map(),
17+
turnCancelled: false,
18+
turnComplete: false,
19+
},
20+
};
21+
}
22+
523
function userMessage(
624
id: string,
725
content: string,
@@ -160,6 +178,19 @@ describe("mergeConversationItems", () => {
160178
expect(result.map((i) => i.id)).toEqual(["setup", "opt"]);
161179
});
162180

181+
it("cloud: keeps a resumed prompt before trailing setup progress", () => {
182+
const result = mergeConversationItems({
183+
conversationItems: [
184+
userMessage("old", "previous prompt"),
185+
progressGroup("restore"),
186+
],
187+
optimisticItems: [userMessage("opt", "follow up", false)],
188+
isCloud: true,
189+
});
190+
191+
expect(result.map((item) => item.id)).toEqual(["old", "opt", "restore"]);
192+
});
193+
163194
it("cloud: does not dedupe historical messages against tail follow-up optimistics", () => {
164195
const result = mergeConversationItems({
165196
conversationItems: [

packages/ui/src/features/sessions/components/mergeConversationItems.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ function strippedUserContent(content: string): string {
2828
}
2929

3030
// Cloud's initial optimistic is pinned to the top so the user's prompt stays
31-
// visible above setup progress. Follow-up optimistics render at the tail until
32-
// the streamed `session/prompt` arrives and replaces them.
31+
// visible above setup progress. Follow-up optimistics render at the tail, but
32+
// before trailing progress cards, to match where the streamed `session/prompt`
33+
// will appear.
3334
//
3435
// Local sessions keep optimistic at the chronological end — they rely on
3536
// `replaceOptimisticWithEvent` to swap optimistic↔real in place.
@@ -103,9 +104,23 @@ export function mergeConversationItems({
103104
};
104105
});
105106

107+
let tailInsertionIndex = dedupedConversation.length;
108+
while (tailInsertionIndex > 0) {
109+
const item = dedupedConversation[tailInsertionIndex - 1];
110+
if (
111+
item.type === "session_update" &&
112+
item.update.sessionUpdate === "progress_group"
113+
) {
114+
tailInsertionIndex--;
115+
} else {
116+
break;
117+
}
118+
}
119+
106120
return [
107121
...resolvedPinnedItems,
108-
...dedupedConversation,
122+
...dedupedConversation.slice(0, tailInsertionIndex),
109123
...tailOptimisticItems,
124+
...dedupedConversation.slice(tailInsertionIndex),
110125
];
111126
}

packages/ui/src/features/sessions/sessionServiceHost.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,9 @@ const mockConvertStoredEntriesToEvents = vi.hoisted(() =>
356356
) => unknown[]
357357
>(() => []),
358358
);
359+
const mockHasSessionPromptEventForTaskRun = vi.hoisted(() =>
360+
vi.fn(() => false),
361+
);
359362

360363
vi.mock("@posthog/core/sessions/sessionEvents", async () => {
361364
const actual = await vi.importActual<
@@ -387,6 +390,7 @@ vi.mock("@posthog/core/sessions/sessionEvents", async () => {
387390
getStoredLogEventPosition: actual.getStoredLogEventPosition,
388391
getUserShellExecutesSinceLastPrompt: vi.fn(() => []),
389392
hasSessionPromptEvent: actual.hasSessionPromptEvent,
393+
hasSessionPromptEventForTaskRun: mockHasSessionPromptEventForTaskRun,
390394
isAbsoluteFolderPath: actual.isAbsoluteFolderPath,
391395
isFatalSessionError: actual.isFatalSessionError,
392396
isRateLimitError: actual.isRateLimitError,
@@ -446,6 +450,7 @@ describe("SessionService", () => {
446450
beforeEach(() => {
447451
vi.clearAllMocks();
448452
mockConvertStoredEntriesToEvents.mockImplementation(() => []);
453+
mockHasSessionPromptEventForTaskRun.mockReturnValue(false);
449454
resetSessionService();
450455
mockSettingsState.customInstructions = "";
451456
mockSettingsState.spokenNotifications = false;
@@ -4374,6 +4379,7 @@ describe("SessionService", () => {
43744379
resumePrompt,
43754380
resumeCompletion,
43764381
]);
4382+
mockHasSessionPromptEventForTaskRun.mockReturnValueOnce(true);
43774383

43784384
service.watchCloudTask(
43794385
"task-123",

0 commit comments

Comments
 (0)