1+ import {
2+ createScriptedProvider ,
3+ makeFakeAssistantMessage ,
4+ makeFakeModel ,
5+ ZERO_USAGE ,
6+ } from '@test/index' ;
17import {
28 type AssistantMessage ,
39 type Context ,
@@ -7,11 +13,6 @@ import {
713 type ModelDescriptor ,
814 type ToolCallContent ,
915} from 'agentic-kit' ;
10- import {
11- createScriptedProvider ,
12- makeFakeAssistantMessage ,
13- makeFakeModel ,
14- } from '@test/index' ;
1516
1617import {
1718 Agent ,
@@ -183,14 +184,7 @@ describe('@agentic-kit/agent', () => {
183184} ) ;
184185
185186function makeUsage ( ) {
186- return {
187- input : 1 ,
188- output : 1 ,
189- cacheRead : 0 ,
190- cacheWrite : 0 ,
191- totalTokens : 2 ,
192- cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
193- } ;
187+ return { ...ZERO_USAGE , cost : { ...ZERO_USAGE . cost } , input : 1 , output : 1 , totalTokens : 2 } ;
194188}
195189
196190describe ( '@agentic-kit/agent — pausable tools' , ( ) => {
@@ -736,3 +730,177 @@ describe('@agentic-kit/agent — maxSteps', () => {
736730 expect ( agent . state . stepCount ) . toBe ( 1 ) ;
737731 } ) ;
738732} ) ;
733+
734+ describe ( '@agentic-kit/agent — totalUsage accumulation' , ( ) => {
735+ function makeUsageTurn ( input : number , output : number ) {
736+ const totalTokens = input + output ;
737+ return {
738+ input,
739+ output,
740+ reasoning : 0 ,
741+ cacheRead : 0 ,
742+ cacheWrite : 0 ,
743+ totalTokens,
744+ cost : { input : input * 0.01 , output : output * 0.02 , cacheRead : 0 , cacheWrite : 0 , total : input * 0.01 + output * 0.02 } ,
745+ } ;
746+ }
747+
748+ it ( 'accumulates totalUsage across two turns and attaches snapshot to turn_end and agent_end events' , async ( ) => {
749+ const turn1Usage = makeUsageTurn ( 10 , 20 ) ;
750+ const turn2Usage = makeUsageTurn ( 30 , 40 ) ;
751+
752+ const responses = [
753+ makeFakeAssistantMessage ( {
754+ stopReason : 'toolUse' ,
755+ usage : turn1Usage ,
756+ content : [ { type : 'toolCall' , id : 'tool_1' , name : 'echo' , arguments : { text : 'hi' } } ] ,
757+ } ) ,
758+ makeFakeAssistantMessage ( {
759+ stopReason : 'stop' ,
760+ usage : turn2Usage ,
761+ content : [ { type : 'text' , text : 'done' } ] ,
762+ } ) ,
763+ ] ;
764+
765+ const provider = createScriptedProvider ( { responses } ) ;
766+ const agent = new Agent ( {
767+ initialState : { model : makeFakeModel ( ) } ,
768+ streamFn : provider . stream ,
769+ } ) ;
770+ agent . setTools ( [
771+ {
772+ name : 'echo' ,
773+ label : 'Echo' ,
774+ description : 'Echo text' ,
775+ parameters : {
776+ type : 'object' ,
777+ properties : { text : { type : 'string' } } ,
778+ required : [ 'text' ] ,
779+ } ,
780+ execute : async ( _id , params ) => ( {
781+ content : [ { type : 'text' , text : String ( params . text ) } ] ,
782+ } ) ,
783+ } ,
784+ ] ) ;
785+
786+ const events : AgentEvent [ ] = [ ] ;
787+ agent . subscribe ( ( e ) => events . push ( e ) ) ;
788+
789+ await agent . prompt ( 'go' ) ;
790+
791+ expect ( agent . state . totalUsage . input ) . toBe ( 40 ) ;
792+ expect ( agent . state . totalUsage . output ) . toBe ( 60 ) ;
793+ expect ( agent . state . totalUsage . totalTokens ) . toBe ( turn1Usage . totalTokens + turn2Usage . totalTokens ) ;
794+ expect ( agent . state . totalUsage . cost . total ) . toBeCloseTo (
795+ turn1Usage . cost . total + turn2Usage . cost . total ,
796+ 10
797+ ) ;
798+
799+ const agentEndEvent = events . find (
800+ ( e ) : e is Extract < AgentEvent , { type : 'agent_end' } > => e . type === 'agent_end'
801+ ) ;
802+ expect ( agentEndEvent ) . toBeDefined ( ) ;
803+ expect ( agentEndEvent ! . totalUsage . input ) . toBe ( agent . state . totalUsage . input ) ;
804+ expect ( agentEndEvent ! . totalUsage . output ) . toBe ( agent . state . totalUsage . output ) ;
805+ expect ( agentEndEvent ! . totalUsage . totalTokens ) . toBe ( agent . state . totalUsage . totalTokens ) ;
806+ expect ( agentEndEvent ! . totalUsage . cost . total ) . toBeCloseTo ( agent . state . totalUsage . cost . total , 10 ) ;
807+
808+ // Decision #17: events carry a snapshot, not a live reference. Mutating
809+ // the agent's state after emit must not leak into the captured event.
810+ const capturedInput = agentEndEvent ! . totalUsage . input ;
811+ const capturedCostTotal = agentEndEvent ! . totalUsage . cost . total ;
812+ agent . state . totalUsage . input = 9999 ;
813+ agent . state . totalUsage . cost . total = 9999 ;
814+ expect ( agentEndEvent ! . totalUsage . input ) . toBe ( capturedInput ) ;
815+ expect ( agentEndEvent ! . totalUsage . cost . total ) . toBe ( capturedCostTotal ) ;
816+
817+ const turnEndEvents = events . filter (
818+ ( e ) : e is Extract < AgentEvent , { type : 'turn_end' } > => e . type === 'turn_end'
819+ ) ;
820+ const lastTurnEnd = turnEndEvents [ turnEndEvents . length - 1 ] ;
821+ expect ( lastTurnEnd ) . toBeDefined ( ) ;
822+ expect ( lastTurnEnd ! . totalUsage . input ) . toBe ( 40 ) ;
823+ } ) ;
824+
825+ it ( 'prompt() resets totalUsage; a second prompt only reflects its own turns' , async ( ) => {
826+ const turn1Usage = makeUsageTurn ( 10 , 20 ) ;
827+ const turn2Usage = makeUsageTurn ( 30 , 40 ) ;
828+
829+ const provider = createScriptedProvider ( {
830+ responses : [
831+ makeFakeAssistantMessage ( { stopReason : 'stop' , usage : turn1Usage , content : [ { type : 'text' , text : 'p1' } ] } ) ,
832+ makeFakeAssistantMessage ( { stopReason : 'stop' , usage : turn2Usage , content : [ { type : 'text' , text : 'p2' } ] } ) ,
833+ ] ,
834+ } ) ;
835+ const agent = new Agent ( {
836+ initialState : { model : makeFakeModel ( ) } ,
837+ streamFn : provider . stream ,
838+ } ) ;
839+
840+ await agent . prompt ( 'first' ) ;
841+ expect ( agent . state . totalUsage . input ) . toBe ( turn1Usage . input ) ;
842+ expect ( agent . state . totalUsage . output ) . toBe ( turn1Usage . output ) ;
843+
844+ await agent . prompt ( 'second' ) ;
845+ expect ( agent . state . totalUsage . input ) . toBe ( turn2Usage . input ) ;
846+ expect ( agent . state . totalUsage . output ) . toBe ( turn2Usage . output ) ;
847+ } ) ;
848+
849+ it ( 'continue() does NOT reset totalUsage — it keeps growing' , async ( ) => {
850+ const turn1Usage = makeUsageTurn ( 10 , 20 ) ;
851+ const turn2Usage = makeUsageTurn ( 30 , 40 ) ;
852+
853+ const pauseResponse = makeFakeAssistantMessage ( {
854+ stopReason : 'toolUse' ,
855+ usage : turn1Usage ,
856+ content : [ { type : 'toolCall' , id : 'tool_1' , name : 'approve' , arguments : { target : 'thing' } } ] ,
857+ } ) ;
858+ const finalResponse = makeFakeAssistantMessage ( {
859+ stopReason : 'stop' ,
860+ usage : turn2Usage ,
861+ content : [ { type : 'text' , text : 'done' } ] ,
862+ } ) ;
863+
864+ const provider = createScriptedProvider ( { responses : [ pauseResponse , finalResponse ] } ) ;
865+
866+ const approveTool : AgentTool = {
867+ name : 'approve' ,
868+ label : 'Approve' ,
869+ description : 'Requires decision' ,
870+ parameters : {
871+ type : 'object' ,
872+ properties : { target : { type : 'string' } } ,
873+ required : [ 'target' ] ,
874+ } ,
875+ decision : {
876+ type : 'object' ,
877+ properties : { approved : { type : 'boolean' } } ,
878+ required : [ 'approved' ] ,
879+ } ,
880+ execute : async ( ) => ( { content : [ { type : 'text' , text : 'ok' } ] } ) ,
881+ } ;
882+
883+ const agent = new Agent ( {
884+ initialState : { model : makeFakeModel ( ) } ,
885+ streamFn : provider . stream ,
886+ } ) ;
887+ agent . setTools ( [ approveTool ] ) ;
888+
889+ await agent . prompt ( 'go' ) ;
890+ expect ( agent . state . totalUsage . input ) . toBe ( turn1Usage . input ) ;
891+
892+ const messages = agent . state . messages ;
893+ const last = messages [ messages . length - 1 ] as ReturnType < typeof makeFakeAssistantMessage > ;
894+ const updatedContent = last . content . map ( ( block ) =>
895+ block . type === 'toolCall' && block . id === 'tool_1'
896+ ? ( { ...block , decision : { approved : true } } as ToolCallContent )
897+ : block
898+ ) ;
899+ agent . replaceMessages ( [ ...messages . slice ( 0 , - 1 ) , { ...last , content : updatedContent } ] ) ;
900+
901+ await agent . continue ( ) ;
902+
903+ expect ( agent . state . totalUsage . input ) . toBe ( turn1Usage . input + turn2Usage . input ) ;
904+ expect ( agent . state . totalUsage . output ) . toBe ( turn1Usage . output + turn2Usage . output ) ;
905+ } ) ;
906+ } ) ;
0 commit comments