Skip to content

Commit 79e156c

Browse files
authored
fix(sessions): notify cloud turn completions in either log order (#3682)
1 parent 44157f1 commit 79e156c

3 files changed

Lines changed: 114 additions & 8 deletions

File tree

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

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,35 @@ import { SessionService, type SessionServiceDeps } from "./sessionService";
66
const TASK_ID = "task-1";
77
const RUN_ID = "run-1";
88

9-
function turnComplete(timestamp?: string): StoredLogEntry {
9+
function turnComplete(
10+
timestamp?: string,
11+
stopReason = "end_turn",
12+
): StoredLogEntry {
1013
return {
1114
type: "notification",
1215
timestamp,
1316
notification: {
1417
method: "_posthog/turn_complete",
15-
params: { sessionId: RUN_ID, stopReason: "end_turn" },
18+
params: { sessionId: RUN_ID, stopReason },
1619
},
1720
};
1821
}
1922

23+
// The agent's JSON-RPC response to `session/prompt`. Real logs carry it next
24+
// to `_posthog/turn_complete` in either order (the two writes race in the
25+
// agent's log stream), so completion cases must ring for both orderings.
26+
function promptResponse(
27+
id: number,
28+
stopReason = "end_turn",
29+
timestamp?: string,
30+
): StoredLogEntry {
31+
return {
32+
type: "notification",
33+
timestamp,
34+
notification: { id, result: { stopReason } },
35+
};
36+
}
37+
2038
// The `session/prompt` request that opens a turn. Its arrival is what arms the
2139
// turn's single completion notification, so realistic sequences pair it with a
2240
// later `turn_complete`.
@@ -235,6 +253,50 @@ describe("cloud task update notifications", () => {
235253
],
236254
expected: 1,
237255
},
256+
{
257+
label: "a turn whose response precedes turn_complete",
258+
updates: [
259+
logsUpdate([sessionPrompt(1), promptResponse(1), turnComplete()], 3),
260+
],
261+
expected: 1,
262+
},
263+
{
264+
label: "a turn whose response follows turn_complete",
265+
updates: [
266+
logsUpdate([sessionPrompt(1), turnComplete(), promptResponse(1)], 3),
267+
],
268+
expected: 1,
269+
},
270+
{
271+
label: "a duplicate turn_complete after a response-first turn",
272+
updates: [
273+
logsUpdate([sessionPrompt(1), promptResponse(1), turnComplete()], 3),
274+
logsUpdate([turnComplete()], 4),
275+
],
276+
expected: 1,
277+
},
278+
{
279+
label: "several turns with responses on both sides of turn_complete",
280+
updates: [
281+
logsUpdate([sessionPrompt(1), promptResponse(1), turnComplete()], 3),
282+
logsUpdate([sessionPrompt(2), turnComplete(), promptResponse(2)], 6),
283+
],
284+
expected: 2,
285+
},
286+
{
287+
label: "a cancelled turn",
288+
updates: [
289+
logsUpdate(
290+
[
291+
sessionPrompt(1),
292+
promptResponse(1, "cancelled"),
293+
turnComplete(undefined, "cancelled"),
294+
],
295+
3,
296+
),
297+
],
298+
expected: 0,
299+
},
238300
{
239301
// Opening a task mid-turn: its session/prompt is already in history and
240302
// only the turn_complete arrives live. The completion must still ring.
@@ -278,6 +340,27 @@ describe("cloud task update notifications", () => {
278340
expect(harness.markActivity).toHaveBeenCalledTimes(1);
279341
});
280342

343+
it("keeps the turn duration when the response precedes turn_complete", () => {
344+
const harness = createHarness();
345+
harness.sendUpdate(
346+
logsUpdate(
347+
[
348+
sessionPrompt(1, "2026-01-01T00:00:00Z"),
349+
promptResponse(1, "end_turn", "2026-01-01T00:00:44Z"),
350+
turnComplete("2026-01-01T00:00:45Z"),
351+
],
352+
3,
353+
),
354+
);
355+
356+
expect(harness.notifyPromptComplete).toHaveBeenCalledWith(
357+
"Cloud Task",
358+
"end_turn",
359+
TASK_ID,
360+
45_000,
361+
);
362+
});
363+
281364
it("notifies a pending permission once across repeated snapshots", () => {
282365
const harness = createHarness();
283366
const snapshot = () =>

packages/core/src/sessions/sessionService.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2863,6 +2863,16 @@ export class SessionService {
28632863
if (session && session.currentPromptId !== msg.id) {
28642864
continue;
28652865
}
2866+
if (session?.isCloud) {
2867+
// Cloud logs carry both this response and `_posthog/turn_complete`,
2868+
// in either order (they race in the agent's log stream). Only
2869+
// turn_complete may disarm the turn; disarming here would make the
2870+
// completion notification fire or vanish based on log line order.
2871+
this.d.store.updateSession(taskRunId, {
2872+
isPromptPending: false,
2873+
});
2874+
continue;
2875+
}
28662876
this.d.store.updateSession(taskRunId, {
28672877
isPromptPending: false,
28682878
promptStartedAt: null,
@@ -2874,13 +2884,15 @@ export class SessionService {
28742884
}
28752885
if (isTurnCompleteEvent(acpMsg)) {
28762886
// Local sessions use the JSON-RPC response as the canonical turn-done
2877-
// signal; clearing currentPromptId here would race the id-match guard
2878-
// above. Cloud sessions never see that response.
2887+
// signal; turn_complete is the cloud one, so only cloud disarms here.
28792888
const session = this.getSessionByRunId(taskRunId);
28802889
if (session?.isCloud) {
28812890
const completedActiveTurn =
28822891
session.currentPromptId !== null &&
28832892
session.currentPromptId !== undefined;
2893+
const stopReason =
2894+
(msg as { params?: { stopReason?: string } }).params?.stopReason ??
2895+
"end_turn";
28842896
const turnStartedAtTs =
28852897
this.liveTurnContent.get(taskRunId)?.startedAtTs ??
28862898
session.promptStartedAt;
@@ -2891,10 +2903,14 @@ export class SessionService {
28912903
});
28922904
if (isLive) {
28932905
// Queued messages will start a new turn — suppress the "done" notification in that case.
2894-
if (completedActiveTurn && session.messageQueue.length === 0) {
2906+
if (
2907+
completedActiveTurn &&
2908+
stopReason === "end_turn" &&
2909+
session.messageQueue.length === 0
2910+
) {
28952911
this.d.notifyPromptComplete(
28962912
session.taskTitle,
2897-
"end_turn",
2913+
stopReason,
28982914
session.taskId,
28992915
turnStartedAtTs ? acpMsg.ts - turnStartedAtTs : undefined,
29002916
);

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,11 +1644,18 @@ describe("SessionService", () => {
16441644
"run-123",
16451645
expect.objectContaining({
16461646
isPromptPending: false,
1647-
promptStartedAt: null,
1648-
currentPromptId: null,
16491647
}),
16501648
);
16511649
});
1650+
// The response must not disarm the turn: `_posthog/turn_complete` is the
1651+
// cloud turn-done signal and still needs currentPromptId to notify.
1652+
const disarmed = mockSessionStoreSetters.updateSession.mock.calls.some(
1653+
(call) =>
1654+
call[0] === "run-123" &&
1655+
(call[1] as Record<string, unknown> | undefined)?.currentPromptId ===
1656+
null,
1657+
);
1658+
expect(disarmed).toBe(false);
16521659
});
16531660

16541661
it("flushes queued cloud messages on _posthog/turn_complete", async () => {

0 commit comments

Comments
 (0)