@@ -6,17 +6,35 @@ import { SessionService, type SessionServiceDeps } from "./sessionService";
66const TASK_ID = "task-1" ;
77const 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 = ( ) =>
0 commit comments