@@ -10,8 +10,8 @@ import { buildPaneList, foldTurnWork, formatTurnWorkCounts, isPendingWorkBlock,
1010import type { PaneListItem , TurnWorkBlockItem } from '../utils/turnWork' ;
1111
1212/*
13- * The work block is a pure fold over the message list: where a turn's tool
14- * calls start and end , what stays outside, and what the summary row counts.
13+ * The work block is a pure fold over the message list: which runs of tool
14+ * calls become blocks , what stays outside, and what the summary row counts.
1515 */
1616
1717const at = ( seconds : number ) => new Date ( Date . UTC ( 2026 , 8 , 2 , 0 , 0 , seconds ) ) . toISOString ( ) ;
@@ -35,7 +35,7 @@ const label = (item: PaneListItem | ChatMessage | TurnWorkBlockItem | MessageLis
3535 return `${ message . type } :${ message . content } ` ;
3636} ;
3737
38- test ( 'a turn with tool calls folds them into one block, from the first call to the last ' , ( ) => {
38+ test ( 'a turn with tool calls folds each run of consecutive calls into one block ' , ( ) => {
3939 const items = foldTurnWork ( [
4040 user ( 'go' , 0 ) ,
4141 text ( 'Let me look.' , 1 ) ,
@@ -46,27 +46,33 @@ test('a turn with tool calls folds them into one block, from the first call to t
4646
4747 assert . deepEqual ( items . map ( label ) , [ 'user:go' , 'assistant:Let me look.' , '[work read bash]' , 'assistant:Done.' ] ) ;
4848 const block = items [ 2 ] as TurnWorkBlockItem ;
49- assert . equal ( block . turnStartedAt , at ( 0 ) ) ;
50- assert . equal ( block . turnEndedAt , at ( 4 ) ) ;
51- assert . equal ( block . isLastTurn , true ) ;
49+ // The block's clock starts at the prose before it, not the user message.
50+ assert . equal ( block . startedAt , at ( 1 ) ) ;
51+ assert . equal ( block . endedAt , at ( 4 ) ) ;
52+ assert . equal ( block . isTail , true ) ;
5253} ) ;
5354
54- test ( 'narration between calls stays inside the block, in order; the answer after the last call stays out' , ( ) => {
55- // The transcript is read in order and the model writes between calls, so the
56- // block cannot know a sentence is "the answer" until nothing follows it.
57- // Everything between the first and last call is scaffolding around the
58- // answer; what comes after the last call is the answer.
55+ test ( 'prose between calls stays outside, in order, and cuts the work into one block per run of calls' , ( ) => {
56+ // What the model says is never behind a fold: the turn reads as prose and
57+ // work alternating, the way Codex and Cursor lay it out.
5958 const items = foldTurnWork ( [
6059 user ( 'go' , 0 ) ,
6160 call ( 'read' , 1 ) ,
6261 text ( 'Found it, now checking the tests.' , 2 ) ,
6362 call ( 'bash' , 3 ) ,
64- text ( 'All green.' , 4 ) ,
63+ call ( 'bash' , 4 ) ,
64+ text ( 'All green.' , 5 ) ,
6565 ] ) ;
6666
67- assert . deepEqual ( items . map ( label ) , [ 'user:go' , '[work read assistant:Found it, now checking the tests. bash]' , 'assistant:All green.' ] ) ;
67+ assert . deepEqual ( items . map ( label ) , [
68+ 'user:go' , '[work read]' , 'assistant:Found it, now checking the tests.' , '[work bash bash]' , 'assistant:All green.' ,
69+ ] ) ;
70+ const blocks = items . filter ( isTurnWorkBlockItem ) ;
71+ assert . deepEqual ( blocks . map ( ( block ) => [ block . startedAt , block . endedAt ] ) , [ [ at ( 0 ) , at ( 2 ) ] , [ at ( 2 ) , at ( 5 ) ] ] ) ;
72+ // Only the last block can be the one a run is working on.
73+ assert . deepEqual ( blocks . map ( ( block ) => block . isTail ) , [ false , true ] ) ;
6874
69- // A call arriving after that answer turns it into narration : it moves inside .
75+ // A call arriving after that prose does not move it : it opens a new block below .
7076 const later = foldTurnWork ( [
7177 user ( 'go' , 0 ) ,
7278 call ( 'read' , 1 ) ,
@@ -75,13 +81,19 @@ test('narration between calls stays inside the block, in order; the answer after
7581 text ( 'All green.' , 4 ) ,
7682 call ( 'edit' , 5 ) ,
7783 ] ) ;
78- assert . deepEqual ( later . map ( label ) , [ 'user:go' , '[work read assistant:Found it, now checking the tests. bash assistant:All green. edit]' ] ) ;
84+ assert . deepEqual ( later . map ( label ) , [
85+ 'user:go' , '[work read]' , 'assistant:Found it, now checking the tests.' , '[work bash]' , 'assistant:All green.' , '[work edit]' ,
86+ ] ) ;
87+ assert . deepEqual ( later . filter ( isTurnWorkBlockItem ) . map ( ( block ) => block . isTail ) , [ false , false , true ] ) ;
7988} ) ;
8089
81- test ( 'reasoning is not work: a thought inside the span is hoisted ahead of the block' , ( ) => {
90+ test ( 'reasoning is not work: a thought between calls is hoisted ahead of the block and does not split it ' , ( ) => {
8291 const items = foldTurnWork ( [ user ( 'go' , 0 ) , call ( 'read' , 1 ) , thought ( 2 ) , call ( 'bash' , 3 ) ] ) ;
83-
8492 assert . deepEqual ( items . map ( label ) , [ 'user:go' , 'thought' , '[work read bash]' ] ) ;
93+
94+ // A thought after the run's last call falls outside, after the block.
95+ const trailing = foldTurnWork ( [ user ( 'go' , 0 ) , call ( 'read' , 1 ) , thought ( 2 ) , text ( 'so' , 3 ) , thought ( 4 ) ] ) ;
96+ assert . deepEqual ( trailing . map ( label ) , [ 'user:go' , '[work read]' , 'thought' , 'assistant:so' , 'thought' ] ) ;
8597} ) ;
8698
8799test ( 'every turn gets its own block, and only the last turn can be the live one' , ( ) => {
@@ -97,16 +109,16 @@ test('every turn gets its own block, and only the last turn can be the live one'
97109 'user:third' , '[work bash]' ,
98110 ] ) ;
99111 const blocks = items . filter ( isTurnWorkBlockItem ) ;
100- assert . deepEqual ( blocks . map ( ( block ) => block . isLastTurn ) , [ false , true ] ) ;
101- assert . deepEqual ( blocks . map ( ( block ) => block . turnStartedAt ) , [ at ( 0 ) , at ( 5 ) ] ) ;
102- assert . deepEqual ( blocks . map ( ( block ) => block . turnEndedAt ) , [ at ( 2 ) , null ] ) ;
112+ assert . deepEqual ( blocks . map ( ( block ) => block . isTail ) , [ false , true ] ) ;
113+ assert . deepEqual ( blocks . map ( ( block ) => block . startedAt ) , [ at ( 0 ) , at ( 5 ) ] ) ;
114+ assert . deepEqual ( blocks . map ( ( block ) => block . endedAt ) , [ at ( 2 ) , null ] ) ;
103115} ) ;
104116
105117test ( 'a window that starts mid-turn still folds, without a turn start' , ( ) => {
106118 const items = foldTurnWork ( [ call ( 'read' , 1 ) , call ( 'bash' , 2 ) , text ( 'done' , 3 ) ] ) ;
107119
108120 assert . deepEqual ( items . map ( label ) , [ '[work read bash]' , 'assistant:done' ] ) ;
109- assert . equal ( ( items [ 0 ] as TurnWorkBlockItem ) . turnStartedAt , null ) ;
121+ assert . equal ( ( items [ 0 ] as TurnWorkBlockItem ) . startedAt , null ) ;
110122} ) ;
111123
112124test ( 'a lone read is still a block: one rule, no special case' , ( ) => {
@@ -119,9 +131,9 @@ test('a live turn has a block before its first tool call: empty, at the end of t
119131 assert . deepEqual ( justSent . map ( label ) , [ 'user:go' , '[work ]' ] ) ;
120132 const block = justSent [ 1 ] as TurnWorkBlockItem ;
121133 assert . equal ( isPendingWorkBlock ( block ) , true ) ;
122- assert . equal ( block . isLastTurn , true ) ;
123- assert . equal ( block . turnStartedAt , at ( 0 ) ) ;
124- assert . equal ( block . turnEndedAt , null ) ;
134+ assert . equal ( block . isTail , true ) ;
135+ assert . equal ( block . startedAt , at ( 0 ) ) ;
136+ assert . equal ( block . endedAt , null ) ;
125137 assert . equal ( block . timestamp , at ( 0 ) ) ;
126138
127139 // Prose before the first call stays above the block, as it does once a call lands.
@@ -133,6 +145,18 @@ test('a live turn has a block before its first tool call: empty, at the end of t
133145 const started = foldTurnWork ( [ user ( 'go' , 0 ) , text ( 'Let me look.' , 1 ) , call ( 'read' , 2 ) ] , 'balanced' , { running : true } ) ;
134146 assert . deepEqual ( started . map ( label ) , [ 'user:go' , 'assistant:Let me look.' , '[work read]' ] ) ;
135147 assert . equal ( isPendingWorkBlock ( started [ 2 ] as TurnWorkBlockItem ) , false ) ;
148+ assert . equal ( ( started [ 2 ] as TurnWorkBlockItem ) . isTail , true ) ;
149+
150+ // Prose after a block closes it - it reads `Worked for` - and a new empty
151+ // block follows the prose until the run says what comes next.
152+ const narratingAgain = foldTurnWork ( [ user ( 'go' , 0 ) , call ( 'read' , 1 ) , text ( 'Found it.' , 2 ) ] , 'balanced' , { running : true } ) ;
153+ assert . deepEqual ( narratingAgain . map ( label ) , [ 'user:go' , '[work read]' , 'assistant:Found it.' , '[work ]' ] ) ;
154+ assert . deepEqual ( narratingAgain . filter ( isTurnWorkBlockItem ) . map ( ( block ) => block . isTail ) , [ false , true ] ) ;
155+ assert . equal ( ( narratingAgain [ 3 ] as TurnWorkBlockItem ) . startedAt , at ( 2 ) ) ;
156+ // The next call fills it in place.
157+ const secondRun = foldTurnWork ( [ user ( 'go' , 0 ) , call ( 'read' , 1 ) , text ( 'Found it.' , 2 ) , call ( 'edit' , 3 ) ] , 'balanced' , { running : true } ) ;
158+ assert . deepEqual ( secondRun . map ( label ) , [ 'user:go' , '[work read]' , 'assistant:Found it.' , '[work edit]' ] ) ;
159+ assert . deepEqual ( secondRun . filter ( isTurnWorkBlockItem ) . map ( ( block ) => [ isPendingWorkBlock ( block ) , block . isTail ] ) , [ [ false , false ] , [ false , true ] ] ) ;
136160
137161 // Only the last turn can be live: earlier tool-less turns never get one.
138162 const history = foldTurnWork ( [ user ( 'first' , 0 ) , text ( 'no tools' , 1 ) , user ( 'second' , 2 ) ] , 'compact' , { running : true } ) ;
@@ -153,9 +177,11 @@ test('a turn that finishes with no tool call has no block: the answer stands alo
153177 // While it streams there is one; when the run ends it is gone.
154178 assert . deepEqual ( buildPaneList ( pureText , 'balanced' , { running : true } ) . map ( label ) , [ 'user:go' , 'assistant:Sure, here is the answer.' , '[work ]' ] ) ;
155179 assert . equal ( buildPaneList ( pureText , 'balanced' , { running : false } ) . some ( isTurnWorkBlockItem ) , false ) ;
156- // Finished blocks are never pending, whatever the run state says.
180+ // A block with calls in it is never pending, whatever the run state says;
181+ // while running, the prose after it gets the pending one.
157182 const finished = buildPaneList ( [ user ( 'go' , 0 ) , call ( 'read' , 1 ) , text ( 'done' , 2 ) ] , 'balanced' , { running : true } ) ;
158- assert . deepEqual ( finished . filter ( isTurnWorkBlockItem ) . map ( isPendingWorkBlock ) , [ false ] ) ;
183+ assert . deepEqual ( finished . filter ( isTurnWorkBlockItem ) . map ( isPendingWorkBlock ) , [ false , true ] ) ;
184+ assert . deepEqual ( buildPaneList ( [ user ( 'go' , 0 ) , call ( 'read' , 1 ) , text ( 'done' , 2 ) ] , 'balanced' ) . filter ( isTurnWorkBlockItem ) . map ( isPendingWorkBlock ) , [ false ] ) ;
159185} ) ;
160186
161187test ( 'detailed never folds, live or not: the pane renders a running row of its own instead' , ( ) => {
@@ -204,8 +230,8 @@ test('the summary counts calls by category, edits and writes together', () => {
204230 call ( 'todo_write' , 13 ) ,
205231 text ( 'narration is not counted' , 14 ) ,
206232 ] ,
207- turnStartedAt : null ,
208- turnEndedAt : null ,
233+ startedAt : null ,
234+ endedAt : null ,
209235 } ) ;
210236
211237 assert . equal ( summary . total , 13 ) ;
@@ -217,56 +243,63 @@ test('the summary counts calls by category, edits and writes together', () => {
217243} ) ;
218244
219245test ( 'the summary omits zero counts and pluralises the rest' , ( ) => {
220- const one = summarizeTurnWork ( { messages : [ call ( 'read' , 1 ) , call ( 'bash' , 2 ) ] , turnStartedAt : null , turnEndedAt : null } ) ;
246+ const one = summarizeTurnWork ( { messages : [ call ( 'read' , 1 ) , call ( 'bash' , 2 ) ] , startedAt : null , endedAt : null } ) ;
221247 assert . deepEqual ( formatTurnWorkCounts ( one , t ) , [ '1 file read' , '1 command' ] ) ;
222248
223- const many = summarizeTurnWork ( { messages : [ call ( 'edit' , 1 ) , call ( 'edit' , 2 ) , call ( 'search' , 3 ) ] , turnStartedAt : null , turnEndedAt : null } ) ;
249+ const many = summarizeTurnWork ( { messages : [ call ( 'edit' , 1 ) , call ( 'edit' , 2 ) , call ( 'search' , 3 ) ] , startedAt : null , endedAt : null } ) ;
224250 assert . deepEqual ( formatTurnWorkCounts ( many , t ) , [ '1 search' , '2 edits' ] ) ;
225251} ) ;
226252
227253test ( 'failures are counted, whatever the tool' , ( ) => {
228254 const summary = summarizeTurnWork ( {
229255 messages : [ failedCall ( 'bash' , 1 ) , call ( 'read' , 2 ) , failedCall ( 'edit' , 3 ) , call ( 'bash' , 4 , { toolResult : null } ) ] ,
230- turnStartedAt : null ,
231- turnEndedAt : null ,
256+ startedAt : null ,
257+ endedAt : null ,
232258 } ) ;
233259
234260 assert . equal ( summary . failed , 2 ) ;
235261 assert . equal ( summary . total , 4 ) ;
236262} ) ;
237263
238- test ( 'the duration runs from the turn start to the last thing the transcript saw' , ( ) => {
239- // User message at 0, last result at 42: worked for 42s.
264+ test ( 'the duration runs from the block start to the last thing the transcript saw' , ( ) => {
265+ // Block started at 0, last result at 42: worked for 42s.
240266 const withResults = summarizeTurnWork ( {
241267 messages : [
242268 call ( 'read' , 5 , { toolResult : { content : '' , isError : false , timestamp : at ( 7 ) } } ) ,
243269 call ( 'bash' , 10 , { toolResult : { content : '' , isError : false , timestamp : at ( 42 ) } } ) ,
244270 ] ,
245- turnStartedAt : at ( 0 ) ,
246- turnEndedAt : null ,
271+ startedAt : at ( 0 ) ,
272+ endedAt : null ,
247273 } ) ;
248274 assert . equal ( withResults . durationMs , 42_000 ) ;
249275
250276 // The answer's own timestamp extends it when it is later still.
251- const withAnswer = summarizeTurnWork ( { messages : [ call ( 'read' , 5 ) ] , turnStartedAt : at ( 0 ) , turnEndedAt : at ( 50 ) } ) ;
277+ const withAnswer = summarizeTurnWork ( { messages : [ call ( 'read' , 5 ) ] , startedAt : at ( 0 ) , endedAt : at ( 50 ) } ) ;
252278 assert . equal ( withAnswer . durationMs , 50_000 ) ;
253279
254- // No turn start in the window: measured from the first call instead.
255- const midWindow = summarizeTurnWork ( { messages : [ call ( 'read' , 5 ) , call ( 'bash' , 30 ) ] , turnStartedAt : null , turnEndedAt : null } ) ;
280+ // No start in the window: measured from the first call instead.
281+ const midWindow = summarizeTurnWork ( { messages : [ call ( 'read' , 5 ) , call ( 'bash' , 30 ) ] , startedAt : null , endedAt : null } ) ;
256282 assert . equal ( midWindow . durationMs , 25_000 ) ;
283+
284+ // Each block of a turn measures its own run: from the prose before it to the prose after.
285+ const [ first , second ] = foldTurnWork ( [
286+ user ( 'go' , 0 ) , call ( 'read' , 5 ) , text ( 'Found it.' , 12 ) , call ( 'edit' , 20 ) , text ( 'Done.' , 25 ) ,
287+ ] ) . filter ( isTurnWorkBlockItem ) ;
288+ assert . equal ( summarizeTurnWork ( first ) . durationMs , 12_000 ) ;
289+ assert . equal ( summarizeTurnWork ( second ) . durationMs , 13_000 ) ;
257290} ) ;
258291
259292test ( 'a duration is omitted rather than guessed when the timestamps cannot support one' , ( ) => {
260293 // A single instant is not a duration.
261- assert . equal ( summarizeTurnWork ( { messages : [ call ( 'read' , 5 ) ] , turnStartedAt : null , turnEndedAt : null } ) . durationMs , null ) ;
262- assert . equal ( summarizeTurnWork ( { messages : [ call ( 'read' , 5 ) ] , turnStartedAt : at ( 5 ) , turnEndedAt : null } ) . durationMs , null ) ;
294+ assert . equal ( summarizeTurnWork ( { messages : [ call ( 'read' , 5 ) ] , startedAt : null , endedAt : null } ) . durationMs , null ) ;
295+ assert . equal ( summarizeTurnWork ( { messages : [ call ( 'read' , 5 ) ] , startedAt : at ( 5 ) , endedAt : null } ) . durationMs , null ) ;
263296 // Unparseable timestamps are ignored, not turned into NaN.
264297 const garbage = summarizeTurnWork ( {
265298 messages : [ call ( 'read' , 1 , { timestamp : 'not a date' , toolResult : { content : '' , isError : false , timestamp : 'nope' } } ) ] ,
266- turnStartedAt : 'when?' ,
267- turnEndedAt : null ,
299+ startedAt : 'when?' ,
300+ endedAt : null ,
268301 } ) ;
269302 assert . equal ( garbage . durationMs , null ) ;
270303 // Out-of-order clocks (end before start) give nothing rather than a negative.
271- assert . equal ( summarizeTurnWork ( { messages : [ call ( 'read' , 1 ) ] , turnStartedAt : at ( 9 ) , turnEndedAt : null } ) . durationMs , null ) ;
304+ assert . equal ( summarizeTurnWork ( { messages : [ call ( 'read' , 1 ) ] , startedAt : at ( 9 ) , endedAt : null } ) . durationMs , null ) ;
272305} ) ;
0 commit comments