@@ -16,7 +16,7 @@ import { join } from "node:path";
1616import { describe , test } from "node:test" ;
1717
1818import type { AsyncContext } from "@superdurable/dex" ;
19- import { createCall , deriveCallID , type Call } from "@superdurable/connectors-sdk" ;
19+ import { createCall , deriveCallID , newQueryBranch , type Call , type QueryAttempt } from "@superdurable/connectors-sdk" ;
2020import { Fence } from "@superdurable/connectors-sdk/singledispatch" ;
2121
2222import {
@@ -32,6 +32,7 @@ import {
3232 type ConnectorPackageManifest ,
3333} from "../../src/connectors/index.js" ;
3434import { isProvenNotSent , lowerCamelToSnake , mayHaveDispatched } from "../../src/connectors/connector-tool-source.js" ;
35+ import type { DeferredToolExecution } from "../../src/agent-framework-runner.js" ;
3536import {
3637 buildConnectorToolApprovalDisplay ,
3738 isCredentialLikeName ,
@@ -390,15 +391,17 @@ describe("connector tool approval", () => {
390391} ) ;
391392
392393describe ( "the Step context the single-dispatch fence uses" , ( ) => {
393- test ( "it exposes only what the fence reads, plus the Call identities" , ( ) => {
394+ test ( "it exposes only what the fence reads, plus the identities and times connectors read " , ( ) => {
394395 const members = Object . getOwnPropertyNames ( ConnectorStepContext . prototype )
395396 . filter ( ( name ) => name !== "constructor" )
396397 . sort ( ) ;
397398 assert . deepEqual ( members , [
398399 "attempt" ,
399400 "cancellationSignal" ,
400401 "deadline" ,
402+ "firstAttemptAt" ,
401403 "flowId" ,
404+ "flowStartedAt" ,
402405 "getLastHeartbeatValue" ,
403406 "hasLastHeartbeatValue" ,
404407 "recordHeartbeat" ,
@@ -407,6 +410,33 @@ describe("the Step context the single-dispatch fence uses", () => {
407410 ] ) ;
408411 } ) ;
409412
413+ test ( "an operation reads the Flow start and first attempt times through ConnectorToolSource" , async ( ) => {
414+ const source = new ConnectorToolSource ( [
415+ connectorTool ( new StepTimesBalanceOperation ( ) , ledgerConnection , {
416+ manifest,
417+ inputValidator : GetBalanceInputSchema ,
418+ name : "get_balance" ,
419+ } ) ,
420+ ] ) ;
421+ const flowStartedAt = new Date ( "2026-11-13T08:00:00Z" ) ;
422+ const firstAttemptAt = new Date ( "2026-11-13T08:00:05Z" ) ;
423+ const live = {
424+ flowId : "session-1" ,
425+ runId : "run-1" ,
426+ flowStartedAt,
427+ stepExecutionId : "CallTool-3" ,
428+ firstAttemptAt,
429+ attempt : 1 ,
430+ deadline : new Date ( Date . now ( ) + 60_000 ) ,
431+ cancellationSignal : new AbortController ( ) . signal ,
432+ } as unknown as AsyncContext ;
433+ const result = await source . executeDeferredToolCall ( stepTimesExecution ( live ) ) ;
434+ assert . deepEqual ( JSON . parse ( result . output ) , {
435+ accountId : "acct-1" ,
436+ amount : firstAttemptAt . getTime ( ) - flowStartedAt . getTime ( ) ,
437+ } ) ;
438+ } ) ;
439+
410440 test ( "every fence method keeps its marker through the session checkpoint" , async ( ) => {
411441 const first = new SessionCheckpoints ( ) ;
412442 const fence = new Fence ( fencedCall ( 1 , undefined , first ) , 30_000 ) ;
@@ -618,3 +648,54 @@ function fencedCallWithContext(attempt: number, previous: string | undefined, ch
618648 } ) ;
619649 return { call, context } ;
620650}
651+
652+ /** getBalance that reports the Step times it reads, as released connectors read them. */
653+ class StepTimesBalanceOperation extends GetBalanceOperation {
654+ public constructor ( ) {
655+ super ( provider ) ;
656+ }
657+
658+ public override async invoke (
659+ call : Call ,
660+ input : { readonly accountId : string } ,
661+ ) : Promise < QueryAttempt < { accountId : string ; amount : number } > > {
662+ const amount = call . context . firstAttemptAt . getTime ( ) - call . context . flowStartedAt . getTime ( ) ;
663+ return newQueryBranch ( "found" , { accountId : input . accountId , amount } ) ;
664+ }
665+ }
666+
667+ function stepTimesExecution ( context : AsyncContext ) : DeferredToolExecution {
668+ return {
669+ flowId : "session-1" ,
670+ toolCall : {
671+ toolCallId : "call-1" ,
672+ turnId : "turn-1" ,
673+ toolName : "get_balance" ,
674+ arguments : '{"accountId": "acct-1"}' ,
675+ effect : "read" ,
676+ requiresApproval : false ,
677+ status : "approved" ,
678+ output : null ,
679+ error : null ,
680+ rejectionMessage : null ,
681+ deferralOrder : 0 ,
682+ toolSourceReference : JSON . stringify ( { tool : "get_balance" } ) ,
683+ } ,
684+ idempotencyKey : "session-1/turn-1/call-1" ,
685+ attempt : 1 ,
686+ events : {
687+ turnId : "turn-1" ,
688+ attempt : 1 ,
689+ writeEvent : ( ) => undefined ,
690+ writeTextDelta : ( ) => undefined ,
691+ writeReasoningDelta : ( ) => undefined ,
692+ writeToolCallStarted : ( ) => undefined ,
693+ writeToolCallFinished : ( ) => undefined ,
694+ } ,
695+ cancellationSignal : context . cancellationSignal ,
696+ deadline : context . deadline ,
697+ heartbeat : ( ) => Promise . resolve ( ) ,
698+ supportsToolTasks : false ,
699+ context,
700+ } as unknown as DeferredToolExecution ;
701+ }
0 commit comments