@@ -4,8 +4,14 @@ import { tmpdir } from "node:os";
44import { join } from "node:path" ;
55
66import type { ReactorEmittedEvent } from "@intx/inference" ;
7+ import type {
8+ ContextTransform ,
9+ ConversationTurn ,
10+ StrategyContext ,
11+ } from "@intx/types/runtime" ;
712
813import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js" ;
14+ import { defined } from "../../tests/helpers/defined.js" ;
915import { createPermissionGate } from "../permission/gate.js" ;
1016import type { SubAgentRunSettlement } from "./types.js" ;
1117
@@ -193,3 +199,202 @@ test("pre-progress cancellation settles as cancelled without changing rejection"
193199 } ) ;
194200 expect ( Object . isFrozen ( settlement ) ) . toBe ( true ) ;
195201} ) ;
202+
203+ test ( "tool calls accumulate into family buckets whose sum equals tool_call_count" , async ( ) => {
204+ const cwd = await mkdtemp ( join ( tmpdir ( ) , "corbits-run-buckets-" ) ) ;
205+ const originalError = new Error ( "bucket probe done" ) ;
206+ let settlement : Readonly < SubAgentRunSettlement > | undefined ;
207+ const toolNames = [
208+ "read_file" ,
209+ "read_file" ,
210+ "write_file" ,
211+ "run_shell" ,
212+ "grep" ,
213+ "spawn_agent" ,
214+ "manage_tasks" ,
215+ "mystery_tool" ,
216+ ] ;
217+
218+ const caught = await withMockedModuleDuring (
219+ import . meta. resolve ( "../agent/live-tool-dispatch.js" ) ,
220+ ( real : typeof import ( "../agent/live-tool-dispatch.js" ) ) => ( {
221+ ...real ,
222+ createAgentWithLiveToolDispatch : async ( ) => ( {
223+ send : async ( ) => {
224+ await new Promise ( ( resolve ) => setTimeout ( resolve , 20 ) ) ;
225+ throw originalError ;
226+ } ,
227+ stream : ( ) =>
228+ ( async function * ( ) : AsyncGenerator < ReactorEmittedEvent > {
229+ for ( const [ index , name ] of toolNames . entries ( ) ) {
230+ yield {
231+ type : "tool.start" ,
232+ seq : index + 1 ,
233+ data : {
234+ call : { id : `call-${ index } ` , name, arguments : { } } ,
235+ } ,
236+ } as ReactorEmittedEvent ;
237+ }
238+ } ) ( ) ,
239+ deliver : ( ) => undefined ,
240+ close : async ( ) => undefined ,
241+ setSource : ( ) => undefined ,
242+ setSources : ( ) => undefined ,
243+ history : async ( ) => [ ] ,
244+ checkpoints : async ( ) => [ ] ,
245+ readAt : async ( ) => [ ] ,
246+ blobReader : { } ,
247+ } ) ,
248+ } ) ,
249+ async ( ) => {
250+ const { runSubAgent } = await import ( "./run.js" ) ;
251+ try {
252+ await runSubAgent ( {
253+ cwd,
254+ workdirBase : join ( cwd , ".ctx" ) ,
255+ permissionGate,
256+ provider : {
257+ providerName : "initial" ,
258+ baseURL : "http://localhost" ,
259+ model : "initial-model" ,
260+ } ,
261+ description : "bucket probe" ,
262+ prompt : "use tools across families" ,
263+ onRunSettled : ( summary ) => {
264+ settlement = summary ;
265+ } ,
266+ } ) ;
267+ } catch ( error ) {
268+ return error ;
269+ }
270+ throw new Error ( "expected runSubAgent to reject" ) ;
271+ } ,
272+ ) ;
273+
274+ expect ( caught ) . toBe ( originalError ) ;
275+ expect ( settlement ) . toMatchObject ( {
276+ tool_call_count : 8 ,
277+ tool_read_count : 2 ,
278+ tool_write_count : 1 ,
279+ tool_shell_count : 1 ,
280+ tool_search_count : 1 ,
281+ tool_agent_count : 1 ,
282+ tool_other_count : 2 ,
283+ hydrate_ms : 0 ,
284+ } ) ;
285+ const settled = defined ( settlement ) ;
286+ expect ( settled . tool_call_count ) . toBe (
287+ settled . tool_read_count +
288+ settled . tool_write_count +
289+ settled . tool_shell_count +
290+ settled . tool_search_count +
291+ settled . tool_agent_count +
292+ settled . tool_other_count ,
293+ ) ;
294+ } ) ;
295+
296+ async function runWithRehydrateDelay (
297+ delayMs : number ,
298+ ) : Promise < Readonly < SubAgentRunSettlement > | undefined > {
299+ const cwd = await mkdtemp ( join ( tmpdir ( ) , "corbits-run-hydrate-" ) ) ;
300+ const originalError = new Error ( "hydrate probe done" ) ;
301+ let settlement : Readonly < SubAgentRunSettlement > | undefined ;
302+ let captured : ContextTransform [ ] | undefined ;
303+ const testCtx = { } as StrategyContext ;
304+
305+ await withMockedModuleDuring (
306+ import . meta. resolve ( "../session/attachment-store.js" ) ,
307+ ( real : typeof import ( "../session/attachment-store.js" ) ) => ( {
308+ ...real ,
309+ createAttachmentRehydrateTransform : ( ) => ( {
310+ name : "attachment-rehydrate" ,
311+ version : "1" ,
312+ apply : async ( turns : ConversationTurn [ ] ) => {
313+ if ( delayMs > 0 ) {
314+ await new Promise ( ( resolve ) => setTimeout ( resolve , delayMs ) ) ;
315+ }
316+ return {
317+ output : turns ,
318+ record : {
319+ strategy : "attachment-rehydrate" ,
320+ version : "1" ,
321+ parameters : { } ,
322+ reason : "noop" ,
323+ decisions : { } ,
324+ } ,
325+ } ;
326+ } ,
327+ } ) ,
328+ } ) ,
329+ async ( ) =>
330+ withMockedModuleDuring (
331+ import . meta. resolve ( "../agent/live-tool-dispatch.js" ) ,
332+ ( real : typeof import ( "../agent/live-tool-dispatch.js" ) ) => ( {
333+ ...real ,
334+ createAgentWithLiveToolDispatch : async ( ...args : unknown [ ] ) => {
335+ const env = args [ 1 ] as
336+ | { deps ?: { contextTransforms ?: ContextTransform [ ] } }
337+ | undefined ;
338+ captured = env ?. deps ?. contextTransforms ;
339+ return {
340+ send : async ( ) => {
341+ const rehydrate = captured ?. find (
342+ ( transform ) => transform . name === "attachment-rehydrate" ,
343+ ) ;
344+ await rehydrate ?. apply ( [ ] , testCtx ) ;
345+ throw originalError ;
346+ } ,
347+ stream : ( ) =>
348+ ( async function * ( ) : AsyncGenerator < ReactorEmittedEvent > {
349+ yield * [ ] ;
350+ } ) ( ) ,
351+ deliver : ( ) => undefined ,
352+ close : async ( ) => undefined ,
353+ setSource : ( ) => undefined ,
354+ setSources : ( ) => undefined ,
355+ history : async ( ) => [ ] ,
356+ checkpoints : async ( ) => [ ] ,
357+ readAt : async ( ) => [ ] ,
358+ blobReader : { } ,
359+ } as unknown as Awaited <
360+ ReturnType < typeof real . createAgentWithLiveToolDispatch >
361+ > ;
362+ } ,
363+ } ) ,
364+ async ( ) => {
365+ const { runSubAgent } = await import ( "./run.js" ) ;
366+ try {
367+ await runSubAgent ( {
368+ cwd,
369+ workdirBase : join ( cwd , ".ctx" ) ,
370+ permissionGate,
371+ provider : {
372+ providerName : "initial" ,
373+ baseURL : "http://localhost" ,
374+ model : "initial-model" ,
375+ } ,
376+ description : "hydrate probe" ,
377+ prompt : "rehydrate attachments" ,
378+ onRunSettled : ( summary ) => {
379+ settlement = summary ;
380+ } ,
381+ } ) ;
382+ } catch {
383+ return ;
384+ }
385+ throw new Error ( "expected runSubAgent to reject" ) ;
386+ } ,
387+ ) ,
388+ ) ;
389+ return settlement ;
390+ }
391+
392+ test ( "a slow attachment rehydrate accumulates into hydrate_ms" , async ( ) => {
393+ const settlement = await runWithRehydrateDelay ( 60 ) ;
394+ expect ( defined ( settlement ) . hydrate_ms ) . toBeGreaterThanOrEqual ( 50 ) ;
395+ } ) ;
396+
397+ test ( "a noop attachment rehydrate reports hydrate_ms zero" , async ( ) => {
398+ const settlement = await runWithRehydrateDelay ( 0 ) ;
399+ expect ( defined ( settlement ) . hydrate_ms ) . toBe ( 0 ) ;
400+ } ) ;
0 commit comments