11import { afterEach , describe , expect , test } from "bun:test" ;
22import {
3+ OPEN_SPAN_CAPACITY ,
34 RING_CAPACITY ,
45 clear ,
56 end ,
@@ -43,7 +44,7 @@ describe("start / end / mark", () => {
4344 } ) ;
4445
4546 test ( "mark is a completed point-in-time span" , ( ) => {
46- const id = mark ( "adapter.transport" , { transport : "http_sse" } ) ;
47+ const id = mark ( "adapter.transport" , { tags : { transport : "http_sse" } } ) ;
4748 expect ( id . length ) . toBeGreaterThan ( 0 ) ;
4849
4950 const spans = snapshot ( ) ;
@@ -52,6 +53,17 @@ describe("start / end / mark", () => {
5253 expect ( spans [ 0 ] ! . tags ) . toEqual ( { transport : "http_sse" } ) ;
5354 } ) ;
5455
56+ test ( "mark accepts optional parentId like start" , ( ) => {
57+ const turnId = start ( "turn" ) ;
58+ mark ( "adapter.transport" , { parentId : turnId , tags : { transport : "ws" } } ) ;
59+ end ( turnId ) ;
60+
61+ const spans = snapshot ( ) ;
62+ const marked = spans . find ( ( s ) => s . name === "adapter.transport" ) ! ;
63+ expect ( marked . parentId ) . toBe ( turnId ) ;
64+ expect ( marked . tags ) . toEqual ( { transport : "ws" } ) ;
65+ } ) ;
66+
5567 test ( "open spans appear in snapshot without endNs" , ( ) => {
5668 const id = start ( "session" ) ;
5769 const spans = snapshot ( ) ;
@@ -72,6 +84,20 @@ describe("start / end / mark", () => {
7284 expect ( snapshot ( ) ) . toHaveLength ( 0 ) ;
7385 } ) ;
7486
87+ test ( "double-end is a no-op" , ( ) => {
88+ const id = start ( "tool" ) ;
89+ end ( id ) ;
90+ end ( id ) ;
91+ expect ( snapshot ( ) ) . toHaveLength ( 1 ) ;
92+ } ) ;
93+
94+ test ( "end after clear is a no-op" , ( ) => {
95+ const id = start ( "tool" ) ;
96+ clear ( ) ;
97+ end ( id , { count : 1 } ) ;
98+ expect ( snapshot ( ) ) . toHaveLength ( 0 ) ;
99+ } ) ;
100+
75101 test ( "end merges sanitized tags onto the span" , ( ) => {
76102 const id = start ( "tool" , { tags : { tool_id : "t1" } } ) ;
77103 end ( id , { count : 3 , prompt : "secret" } ) ;
@@ -80,11 +106,93 @@ describe("start / end / mark", () => {
80106 } ) ;
81107} ) ;
82108
109+ describe ( "parentId privacy fence" , ( ) => {
110+ test ( "strips path-like parentId" , ( ) => {
111+ const id = start ( "inference" , { parentId : "/Users/me/secret/repo/src/main.ts" } ) ;
112+ end ( id ) ;
113+ expect ( snapshot ( ) [ 0 ] ! . parentId ) . toBeUndefined ( ) ;
114+ } ) ;
115+
116+ test ( "strips free-text and path-separator parentIds" , ( ) => {
117+ const a = start ( "tool" , { parentId : "has spaces and free text" } ) ;
118+ end ( a ) ;
119+ const b = start ( "tool" , { parentId : "../../etc/passwd" } ) ;
120+ end ( b ) ;
121+ const c = start ( "tool" , { parentId : "C:\\Windows\\System32" } ) ;
122+ end ( c ) ;
123+
124+ for ( const span of snapshot ( ) ) {
125+ expect ( span . parentId ) . toBeUndefined ( ) ;
126+ }
127+ } ) ;
128+
129+ test ( "accepts known open span ids and opaque ids" , ( ) => {
130+ const parent = start ( "turn" ) ;
131+ const child = start ( "inference" , { parentId : parent } ) ;
132+ end ( child ) ;
133+ end ( parent ) ;
134+
135+ // Opaque id that matches OPAQUE_ID_RE but is not currently open/ring-known
136+ // after clear of only that id — still accepted when pattern matches.
137+ const orphan = start ( "tool" , { parentId : "parent1" } ) ;
138+ end ( orphan ) ;
139+
140+ const spans = snapshot ( ) ;
141+ expect ( spans . find ( ( s ) => s . name === "inference" ) ! . parentId ) . toBe ( parent ) ;
142+ expect ( spans . find ( ( s ) => s . name === "tool" ) ! . parentId ) . toBe ( "parent1" ) ;
143+ } ) ;
144+
145+ test ( "accepts parentId of a completed (ring) span" , ( ) => {
146+ const parent = start ( "turn" ) ;
147+ end ( parent ) ;
148+ const child = start ( "inference" , { parentId : parent } ) ;
149+ end ( child ) ;
150+ expect ( snapshot ( ) . find ( ( s ) => s . name === "inference" ) ! . parentId ) . toBe ( parent ) ;
151+ } ) ;
152+ } ) ;
153+
154+ describe ( "snapshot immutability" , ( ) => {
155+ test ( "mutating a snapshot does not poison the next snapshot" , ( ) => {
156+ const id = start ( "tool" , { tags : { tool_id : "t1" , count : 1 } } ) ;
157+ end ( id ) ;
158+
159+ const first = snapshot ( ) ;
160+ expect ( first ) . toHaveLength ( 1 ) ;
161+ first [ 0 ] ! . name = "session" ;
162+ first [ 0 ] ! . tags ! . tool_id = "POISON" ;
163+ first [ 0 ] ! . tags ! . count = 999 ;
164+ first [ 0 ] ! . parentId = "injected" ;
165+ first . push ( {
166+ id : "fake" ,
167+ name : "session" ,
168+ startNs : 0n ,
169+ endNs : 0n ,
170+ } ) ;
171+
172+ const second = snapshot ( ) ;
173+ expect ( second ) . toHaveLength ( 1 ) ;
174+ expect ( second [ 0 ] ! . name ) . toBe ( "tool" ) ;
175+ expect ( second [ 0 ] ! . tags ) . toEqual ( { tool_id : "t1" , count : 1 } ) ;
176+ expect ( second [ 0 ] ! . parentId ) . toBeUndefined ( ) ;
177+ } ) ;
178+
179+ test ( "mutating an open-span snapshot does not poison open state" , ( ) => {
180+ start ( "session" , { tags : { provider_id : "openai" } } ) ;
181+ const first = snapshot ( ) ;
182+ first [ 0 ] ! . tags ! . provider_id = "POISON" ;
183+ first [ 0 ] ! . endNs = 1n ;
184+
185+ const second = snapshot ( ) ;
186+ expect ( second [ 0 ] ! . tags ) . toEqual ( { provider_id : "openai" } ) ;
187+ expect ( second [ 0 ] ! . endNs ) . toBeUndefined ( ) ;
188+ } ) ;
189+ } ) ;
190+
83191describe ( "ring overflow" , ( ) => {
84192 test ( "drops oldest completed spans when capacity is exceeded" , ( ) => {
85193 // Fill past capacity with marks (cheap completed spans).
86194 for ( let i = 0 ; i < RING_CAPACITY + 10 ; i += 1 ) {
87- mark ( "tool" , { count : i } ) ;
195+ mark ( "tool" , { tags : { count : i } } ) ;
88196 }
89197
90198 const spans = snapshot ( ) ;
@@ -105,6 +213,30 @@ describe("ring overflow", () => {
105213 } ) ;
106214} ) ;
107215
216+ describe ( "open span capacity" , ( ) => {
217+ test ( "evicts oldest open span when over OPEN_SPAN_CAPACITY" , ( ) => {
218+ const ids : string [ ] = [ ] ;
219+ for ( let i = 0 ; i < OPEN_SPAN_CAPACITY + 5 ; i += 1 ) {
220+ ids . push ( start ( "tool" , { tags : { count : i } } ) ) ;
221+ }
222+
223+ const open = snapshot ( ) . filter ( ( s ) => s . endNs === undefined ) ;
224+ expect ( open ) . toHaveLength ( OPEN_SPAN_CAPACITY ) ;
225+
226+ // Oldest five (count 0..4) were evicted; survivors start at count 5.
227+ const counts = open . map ( ( s ) => s . tags ?. count ) . sort ( ( a , b ) => ( a ?? 0 ) - ( b ?? 0 ) ) ;
228+ expect ( counts [ 0 ] ) . toBe ( 5 ) ;
229+ expect ( counts [ counts . length - 1 ] ) . toBe ( OPEN_SPAN_CAPACITY + 4 ) ;
230+
231+ // Ending an evicted id is a no-op; ending a survivor still works.
232+ end ( ids [ 0 ] ! ) ;
233+ end ( ids [ ids . length - 1 ] ! ) ;
234+ const completed = snapshot ( ) . filter ( ( s ) => s . endNs !== undefined ) ;
235+ expect ( completed ) . toHaveLength ( 1 ) ;
236+ expect ( completed [ 0 ] ! . tags ?. count ) . toBe ( OPEN_SPAN_CAPACITY + 4 ) ;
237+ } ) ;
238+ } ) ;
239+
108240describe ( "sanitizeTags" , ( ) => {
109241 test ( "keeps allowlisted enums, numbers, and opaque ids" , ( ) => {
110242 const tags = sanitizeTags ( {
0 commit comments