@@ -16,6 +16,7 @@ import {
1616 createUserInterruptionMessage ,
1717 prepareUserContent ,
1818 createToolResultStopMessage ,
19+ createProgressMessage ,
1920 extractTag ,
2021 isNotEmptyMessage ,
2122 deriveUUID ,
@@ -28,6 +29,9 @@ import {
2829 DONT_ASK_REJECT_MESSAGE ,
2930 SYNTHETIC_MODEL ,
3031 ensureToolResultPairing ,
32+ buildMessageLookups ,
33+ updateMessageLookupsIncremental ,
34+ computeMessageStructureKey ,
3135} from '../messages'
3236import type {
3337 Message ,
@@ -786,3 +790,168 @@ describe('normalizeMessagesForAPI – thinking + tool_use same turn (CC-1215)',
786790 }
787791 } )
788792} )
793+
794+ // ─── Progress tick replace (Bash/PowerShell elapsed-time freeze) ──────────
795+
796+ describe ( 'computeMessageStructureKey + updateMessageLookupsIncremental: progress replace' , ( ) => {
797+ // REPL.tsx replaces ephemeral progress ticks (Bash/PowerShell/MCP) in-place
798+ // to bound the messages array. The lookups cache must invalidate when the
799+ // trailing progress tick changes, or ShellProgressMessage's elapsed time
800+ // freezes at the first tick forever.
801+
802+ type BashProgress = {
803+ type : 'bash_progress'
804+ elapsedTimeSeconds : number
805+ output : string
806+ fullOutput : string
807+ }
808+
809+ function makeAssistantWithToolUse ( toolUseID : string ) : Message {
810+ return createAssistantMessage ( {
811+ content : [
812+ {
813+ type : 'tool_use' ,
814+ id : toolUseID ,
815+ name : 'Bash' ,
816+ input : { command : 'sleep 10' } ,
817+ } as any ,
818+ ] ,
819+ } )
820+ }
821+
822+ function makeProgress (
823+ parentToolUseID : string ,
824+ uuid : `${string } -${string } -${string } -${string } -${string } `,
825+ elapsedTimeSeconds : number ,
826+ ) {
827+ const msg = createProgressMessage < BashProgress > ( {
828+ toolUseID : `bash-progress-${ elapsedTimeSeconds } ` ,
829+ parentToolUseID,
830+ data : {
831+ type : 'bash_progress' ,
832+ elapsedTimeSeconds,
833+ output : '' ,
834+ fullOutput : '' ,
835+ } ,
836+ } )
837+ // Override uuid so the test is deterministic (createProgressMessage
838+ // generates a random uuid).
839+ return { ...msg , uuid }
840+ }
841+
842+ test ( 'computeMessageStructureKey distinguishes progress ticks by uuid' , ( ) => {
843+ const assistant = makeAssistantWithToolUse ( 'bash-1' )
844+ const normalized = normalizeMessages ( [ assistant ] )
845+
846+ const progress1 = makeProgress (
847+ 'bash-1' ,
848+ '00000000-0000-0000-0000-000000000001' ,
849+ 3 ,
850+ )
851+ const progress2 = makeProgress (
852+ 'bash-1' ,
853+ '00000000-0000-0000-0000-000000000002' ,
854+ 4 ,
855+ )
856+
857+ const keyBefore = computeMessageStructureKey (
858+ [ ...normalized , progress1 as any ] ,
859+ [ ...normalized , progress1 as any ] as any ,
860+ )
861+ const keyAfter = computeMessageStructureKey (
862+ [ ...normalized , progress2 as any ] ,
863+ [ ...normalized , progress2 as any ] as any ,
864+ )
865+
866+ // Same parentToolUseID, same length, but different uuid (tick replace).
867+ // Without uuid in the key, these would be identical and the lookups cache
868+ // would freeze on the first tick.
869+ expect ( keyBefore ) . not . toEqual ( keyAfter )
870+ } )
871+
872+ test ( 'updateMessageLookupsIncremental returns null when trailing progress was replaced (same length)' , ( ) => {
873+ const assistant = makeAssistantWithToolUse ( 'bash-1' )
874+ const normalized = normalizeMessages ( [ assistant ] )
875+
876+ const progress1 = makeProgress (
877+ 'bash-1' ,
878+ '00000000-0000-0000-0000-000000000001' ,
879+ 3 ,
880+ )
881+ const progress2 = makeProgress (
882+ 'bash-1' ,
883+ '00000000-0000-0000-0000-000000000002' ,
884+ 4 ,
885+ )
886+
887+ const withProgress1 = [ ...normalized , progress1 as any ]
888+ const withProgress2 = [ ...normalized , progress2 as any ]
889+
890+ const existing = buildMessageLookups (
891+ withProgress1 as any ,
892+ withProgress1 as any ,
893+ )
894+
895+ // Same length, but the trailing progress is a fresh tick. Returning
896+ // `existing` here would leave progressMessagesByToolUseID stuck on u1.
897+ const result = updateMessageLookupsIncremental (
898+ existing ,
899+ withProgress1 . length ,
900+ withProgress1 . length ,
901+ withProgress2 as any ,
902+ withProgress2 as any ,
903+ )
904+
905+ expect ( result ) . toBeNull ( )
906+ } )
907+
908+ test ( 'updateMessageLookupsIncremental still returns existing when length same and trailing is NOT progress' , ( ) => {
909+ // Protect the original streaming-delta fast path: content-only changes
910+ // on a non-progress trailing message should not trigger a full rebuild.
911+ const assistant = makeAssistantWithToolUse ( 'bash-1' )
912+ const normalized = normalizeMessages ( [ assistant ] )
913+
914+ const existing = buildMessageLookups ( normalized as any , normalized as any )
915+
916+ const result = updateMessageLookupsIncremental (
917+ existing ,
918+ normalized . length ,
919+ normalized . length ,
920+ normalized as any ,
921+ normalized as any ,
922+ )
923+
924+ expect ( result ) . toBe ( existing )
925+ } )
926+
927+ test ( 'full rebuild after progress replace yields the new tick in progressMessagesByToolUseID' , ( ) => {
928+ // End-to-end: buildMessageLookups after a tick replace must reflect the
929+ // fresh progress, not the stale one. This is what Messages.tsx falls back
930+ // to when updateMessageLookupsIncremental returns null.
931+ const assistant = makeAssistantWithToolUse ( 'bash-1' )
932+ const normalized = normalizeMessages ( [ assistant ] )
933+
934+ const progress1 = makeProgress (
935+ 'bash-1' ,
936+ '00000000-0000-0000-0000-000000000001' ,
937+ 3 ,
938+ )
939+ const progress2 = makeProgress (
940+ 'bash-1' ,
941+ '00000000-0000-0000-0000-000000000002' ,
942+ 4 ,
943+ )
944+
945+ const withProgress2 = [ ...normalized , progress2 as any ]
946+ const rebuilt = buildMessageLookups (
947+ withProgress2 as any ,
948+ withProgress2 as any ,
949+ )
950+
951+ const arr = rebuilt . progressMessagesByToolUseID . get ( 'bash-1' )
952+ expect ( arr ) . toBeDefined ( )
953+ expect ( arr ) . toHaveLength ( 1 )
954+ expect ( arr ! [ 0 ] . uuid ) . toBe ( '00000000-0000-0000-0000-000000000002' )
955+ expect ( ( arr ! [ 0 ] . data as BashProgress ) . elapsedTimeSeconds ) . toBe ( 4 )
956+ } )
957+ } )
0 commit comments