77 formatAttributionReport ,
88 spansFromDumpJson ,
99} from "./attribution-report.js" ;
10- import { buildDump , serializeSpan } from "./dump.js" ;
10+ import { DUMP_VERSION , buildDump , serializeSpan } from "./dump.js" ;
1111import {
1212 MULTI_TOOL_TURN_GOLDEN ,
1313 multiToolTurnFixture ,
@@ -148,6 +148,112 @@ describe("attributionFromSpans — subagent + transport", () => {
148148 expect ( report . session . subagentCount ) . toBe ( 1 ) ;
149149 expect ( report . session . transportNs ) . toBe ( 800 ) ;
150150 expect ( report . session . transportShareOfInference ) . toBeCloseTo ( 800 / 4000 , 10 ) ;
151+ expect ( report . session . open ) . toBe ( false ) ;
152+ expect ( report . session . openPhases ) . toEqual ( [ ] ) ;
153+ } ) ;
154+
155+ test ( "nested exclusive under subagent does not double-count (share sum ≈ 1)" , ( ) => {
156+ // turn 10_000
157+ // inference 2000 (top-level exclusive)
158+ // subagent 6000 containing nested inference 2500 + tools 1500
159+ // tool 1000 (sibling exclusive)
160+ // Exclusive: inference=2000, subagent=6000, tools=1000, other=1000
161+ // Nested under subagent must NOT add 2500+1500 into exclusive buckets.
162+ const spans : PerfSpan [ ] = [
163+ span ( { id : "t1" , name : "turn" , startNs : 0n , endNs : 10_000n } ) ,
164+ span ( {
165+ id : "i1" ,
166+ name : "inference" ,
167+ parentId : "t1" ,
168+ startNs : 0n ,
169+ endNs : 2000n ,
170+ } ) ,
171+ span ( {
172+ id : "ttft1" ,
173+ name : "inference.ttft" ,
174+ parentId : "i1" ,
175+ startNs : 0n ,
176+ endNs : 400n ,
177+ } ) ,
178+ span ( {
179+ id : "stream1" ,
180+ name : "inference.stream" ,
181+ parentId : "i1" ,
182+ startNs : 400n ,
183+ endNs : 2000n ,
184+ } ) ,
185+ span ( {
186+ id : "sa1" ,
187+ name : "subagent" ,
188+ parentId : "t1" ,
189+ startNs : 2000n ,
190+ endNs : 8000n ,
191+ } ) ,
192+ span ( {
193+ id : "sa_i1" ,
194+ name : "inference" ,
195+ parentId : "sa1" ,
196+ startNs : 2000n ,
197+ endNs : 4500n ,
198+ } ) ,
199+ span ( {
200+ id : "sa_ttft" ,
201+ name : "inference.ttft" ,
202+ parentId : "sa_i1" ,
203+ startNs : 2000n ,
204+ endNs : 2500n ,
205+ } ) ,
206+ span ( {
207+ id : "sa_stream" ,
208+ name : "inference.stream" ,
209+ parentId : "sa_i1" ,
210+ startNs : 2500n ,
211+ endNs : 4500n ,
212+ } ) ,
213+ span ( {
214+ id : "sa_k1" ,
215+ name : "tool" ,
216+ parentId : "sa1" ,
217+ startNs : 4500n ,
218+ endNs : 6000n ,
219+ } ) ,
220+ span ( {
221+ id : "k1" ,
222+ name : "tool" ,
223+ parentId : "t1" ,
224+ startNs : 8000n ,
225+ endNs : 9000n ,
226+ } ) ,
227+ ] ;
228+
229+ const report = attributionFromSpans ( spans ) ;
230+ const inf = categoryShare ( report . session . categories , "inference" ) ;
231+ const tools = categoryShare ( report . session . categories , "tools" ) ;
232+ const sub = categoryShare ( report . session . categories , "subagent" ) ;
233+ const other = categoryShare ( report . session . categories , "other" ) ;
234+
235+ expect ( inf . ns ) . toBe ( 2000 ) ;
236+ expect ( sub . ns ) . toBe ( 6000 ) ;
237+ expect ( tools . ns ) . toBe ( 1000 ) ; // only the top-level tool, not sa_k1
238+ expect ( other . ns ) . toBe ( 1000 ) ; // 10000 - 2000 - 6000 - 1000
239+ expect ( inf . count ) . toBe ( 1 ) ; // nested inference under subagent not counted
240+ expect ( sub . count ) . toBe ( 1 ) ;
241+ // toolCount still sees nested tools for visibility
242+ expect ( report . session . toolCount ) . toBe ( 2 ) ;
243+ expect ( report . session . subagentCount ) . toBe ( 1 ) ;
244+
245+ const shareSum = report . session . categories . reduce ( ( a , c ) => a + c . share , 0 ) ;
246+ expect ( shareSum ) . toBeCloseTo ( 1 , 10 ) ;
247+
248+ // Nested diagnostics still roll up (parent + subagent ttft/stream)
249+ expect ( report . session . inference . ttftNs ) . toBe ( 400 + 500 ) ;
250+ expect ( report . session . inference . streamNs ) . toBe ( 1600 + 2000 ) ;
251+
252+ const turnShareSum = report . turns [ 0 ] ! . categories . reduce (
253+ ( a , c ) => a + c . share ,
254+ 0 ,
255+ ) ;
256+ expect ( turnShareSum ) . toBeCloseTo ( 1 , 10 ) ;
151257 } ) ;
152258} ) ;
153259
@@ -192,6 +298,10 @@ describe("attributionFromSpans — open (stall) turns", () => {
192298 expect ( report . session . wallNs ) . toBe ( 3000 ) ;
193299 expect ( report . turns [ 0 ] ! . open ) . toBe ( true ) ;
194300 expect ( report . turns [ 0 ] ! . turnNs ) . toBe ( 3000 ) ;
301+ expect ( report . session . open ) . toBe ( true ) ;
302+ // Still-running: turn + open stream (completed inference/tool are not listed)
303+ expect ( report . session . openPhases ) . toEqual ( [ "inference.stream" , "turn" ] ) ;
304+ expect ( report . turns [ 0 ] ! . openPhases ) . toEqual ( [ "inference.stream" , "turn" ] ) ;
195305
196306 expect ( categoryShare ( report . session . categories , "inference" ) . ns ) . toBe ( 2000 ) ;
197307 expect ( categoryShare ( report . session . categories , "tools" ) . ns ) . toBe ( 1000 ) ;
@@ -281,6 +391,9 @@ describe("attributionFromSpans — open (stall) turns", () => {
281391 expect ( report . session . wallNs ) . toBe ( 0 ) ;
282392 expect ( report . turns [ 0 ] ! . open ) . toBe ( true ) ;
283393 expect ( report . turns [ 0 ] ! . turnNs ) . toBe ( 0 ) ;
394+ expect ( report . session . open ) . toBe ( true ) ;
395+ expect ( report . session . openPhases ) . toEqual ( [ "inference" , "turn" ] ) ;
396+ expect ( report . turns [ 0 ] ! . openPhases ) . toEqual ( [ "inference" , "turn" ] ) ;
284397 for ( const c of report . session . categories ) {
285398 expect ( c . share ) . toBe ( 0 ) ;
286399 expect ( c . ns ) . toBe ( 0 ) ;
@@ -308,6 +421,17 @@ describe("dump round-trip", () => {
308421 expect ( spans [ 0 ] ! . startNs ) . toBe ( 0n ) ;
309422 expect ( deserializeDumpSpan ( serialized [ 0 ] ! ) . id ) . toBe ( "t1" ) ;
310423 } ) ;
424+
425+ test ( "attributionFromDump rejects unsupported DUMP_VERSION" , ( ) => {
426+ const dump = buildDump (
427+ multiToolTurnFixture ( ) ,
428+ "fixture-multi" ,
429+ "2026-04-08T00:00:00.000Z" ,
430+ ) ;
431+ expect ( ( ) =>
432+ attributionFromDump ( { ...dump , version : DUMP_VERSION + 1 } ) ,
433+ ) . toThrow ( / u n s u p p o r t e d d u m p v e r s i o n / ) ;
434+ } ) ;
311435} ) ;
312436
313437describe ( "formatAttributionReport" , ( ) => {
@@ -324,5 +448,39 @@ describe("formatAttributionReport", () => {
324448 expect ( text ) . toContain ( "40.0%" ) ; // inference 2000/5000
325449 expect ( text ) . toContain ( "24.0%" ) ; // tools 1200/5000
326450 expect ( text ) . toContain ( "turn t1" ) ;
451+ expect ( text ) . toContain ( "Inference split (of ttft+stream)" ) ;
452+ expect ( text ) . not . toContain ( "Open (incomplete)" ) ;
453+ } ) ;
454+
455+ test ( "surfaces open phases for mid-stall dumps" , ( ) => {
456+ const spans : PerfSpan [ ] = [
457+ span ( { id : "t1" , name : "turn" , startNs : 0n } ) ,
458+ span ( {
459+ id : "i1" ,
460+ name : "inference" ,
461+ parentId : "t1" ,
462+ startNs : 0n ,
463+ endNs : 1000n ,
464+ } ) ,
465+ span ( {
466+ id : "stream1" ,
467+ name : "inference.stream" ,
468+ parentId : "i1" ,
469+ startNs : 200n ,
470+ } ) ,
471+ span ( {
472+ id : "k1" ,
473+ name : "tool" ,
474+ parentId : "t1" ,
475+ startNs : 1000n ,
476+ endNs : 1500n ,
477+ } ) ,
478+ ] ;
479+ const text = formatAttributionReport ( attributionFromSpans ( spans ) ) ;
480+ expect ( text ) . toContain ( "Open (incomplete)" ) ;
481+ expect ( text ) . toContain ( "inference.stream" ) ;
482+ expect ( text ) . toContain ( "open phases:" ) ;
483+ expect ( text ) . toContain ( "shares incomplete" ) ;
484+ expect ( text ) . toContain ( "not a full stall diagnosis" ) ;
327485 } ) ;
328486} ) ;
0 commit comments