@@ -1063,3 +1063,140 @@ func TestWebuiEditResendEchoSeedsNoSecondRebased(t *testing.T) {
10631063 t .Fatalf ("user_message count = %d (types %v), want 1 (echo swallowed)" , got , eventTypes (sub .Events ))
10641064 }
10651065}
1066+
1067+ func userMessageEventWithRef (conversationID string , message string , ref map [string ]any ) * gatewayv2.ChatEvent {
1068+ payload := map [string ]any {"message" : message }
1069+ if ref != nil {
1070+ payload ["message_ref" ] = ref
1071+ }
1072+ data , _ := json .Marshal (payload )
1073+ return & gatewayv2.ChatEvent {
1074+ Type : gatewayv2 .ChatEvent_USER_MESSAGE ,
1075+ ConversationId : conversationID ,
1076+ Data : string (data ),
1077+ }
1078+ }
1079+
1080+ func testNewMessageRef () map [string ]any {
1081+ return map [string ]any {
1082+ "segment_index" : 0 ,
1083+ "message_index" : 4 ,
1084+ "segment_id" : "seg-1" ,
1085+ "message_id" : "msg-9" ,
1086+ "role" : "user" ,
1087+ "content_hash" : "hash-9" ,
1088+ }
1089+ }
1090+
1091+ // The gateway-seeded user_message cannot carry the message's persisted
1092+ // identity (ids are minted at desktop persist time). An echo whose identity
1093+ // arrives only through message_ref (no bare message_id) is forwarded exactly
1094+ // once, replay-safe, so subscribers can anchor a later edit-resend rebase.
1095+ func TestSeededUserMessageForwardsRefIdentityOnce (t * testing.T ) {
1096+ m := NewManager ()
1097+ m .StartChatCommand (conversationTestAgentID , "run-1" , "conv-1" , "" , "client-1" , []map [string ]any {
1098+ {"type" : "user_message" , "message" : "prompt" },
1099+ })
1100+ m .ingestChatControl (conversationTestAgentID , "run-1" , startedControl ("run-1" , "conv-1" ))
1101+ m .ingestChatEvent (conversationTestAgentID , "run-1" , userMessageEventWithRef ("conv-1" , "prompt" , testNewMessageRef ()))
1102+ // Reconnect replay redelivers the same echo: no third bubble.
1103+ m .ingestChatEvent (conversationTestAgentID , "run-1" , userMessageEventWithRef ("conv-1" , "prompt" , testNewMessageRef ()))
1104+
1105+ sub := m .SubscribeConversationStream (conversationTestAgentID , "conv-1" , 0 , "" )
1106+ defer sub .Cleanup ()
1107+ if got := countEventType (sub .Events , "user_message" ); got != 2 {
1108+ t .Fatalf ("user_message count = %d (types %v), want 2 (seed + one forwarded echo)" , got , eventTypes (sub .Events ))
1109+ }
1110+ forwarded := sub .Events [len (sub .Events )- 1 ]
1111+ if forwarded .Type != "user_message" {
1112+ t .Fatalf ("last event = %s, want forwarded user_message" , forwarded .Type )
1113+ }
1114+ ref , ok := forwarded .Payload ["message_ref" ].(map [string ]any )
1115+ if ! ok || ref ["message_id" ] != "msg-9" || ref ["content_hash" ] != "hash-9" {
1116+ t .Fatalf ("forwarded message_ref = %#v" , forwarded .Payload ["message_ref" ])
1117+ }
1118+ }
1119+
1120+ // Echoes without usable identity (absent, null, or blank-id message_ref —
1121+ // old desktop versions) swallow silently, exactly as before.
1122+ func TestSeededEchoWithoutIdentitySwallowed (t * testing.T ) {
1123+ m := NewManager ()
1124+ m .StartChatCommand (conversationTestAgentID , "run-1" , "conv-1" , "" , "client-1" , []map [string ]any {
1125+ {"type" : "user_message" , "message" : "prompt" },
1126+ })
1127+ m .ingestChatControl (conversationTestAgentID , "run-1" , startedControl ("run-1" , "conv-1" ))
1128+ m .ingestChatEvent (conversationTestAgentID , "run-1" , userMessageEventWithRef ("conv-1" , "prompt" , nil ))
1129+
1130+ nullRef , _ := json .Marshal (map [string ]any {"message" : "prompt" , "message_ref" : nil })
1131+ m .ingestChatEvent (conversationTestAgentID , "run-1" , & gatewayv2.ChatEvent {
1132+ Type : gatewayv2 .ChatEvent_USER_MESSAGE , ConversationId : "conv-1" , Data : string (nullRef ),
1133+ })
1134+ blankRef , _ := json .Marshal (map [string ]any {
1135+ "message" : "prompt" ,
1136+ "message_ref" : map [string ]any {"message_id" : " " , "content_hash" : "hash-9" },
1137+ })
1138+ m .ingestChatEvent (conversationTestAgentID , "run-1" , & gatewayv2.ChatEvent {
1139+ Type : gatewayv2 .ChatEvent_USER_MESSAGE , ConversationId : "conv-1" , Data : string (blankRef ),
1140+ })
1141+
1142+ sub := m .SubscribeConversationStream (conversationTestAgentID , "conv-1" , 0 , "" )
1143+ defer sub .Cleanup ()
1144+ if got := countEventType (sub .Events , "user_message" ); got != 1 {
1145+ t .Fatalf ("user_message count = %d (types %v), want 1" , got , eventTypes (sub .Events ))
1146+ }
1147+ }
1148+
1149+ // A GUI-local send is never seeded, so the ref rides inside the single
1150+ // user_message itself.
1151+ func TestGUILocalUserMessageKeepsInlineRef (t * testing.T ) {
1152+ m := NewManager ()
1153+ m .ingestChatControl (conversationTestAgentID , "run-1" , startedControl ("run-1" , "conv-1" ))
1154+ m .ingestChatEvent (conversationTestAgentID , "run-1" , userMessageEventWithRef ("conv-1" , "prompt" , testNewMessageRef ()))
1155+
1156+ sub := m .SubscribeConversationStream (conversationTestAgentID , "conv-1" , 0 , "" )
1157+ defer sub .Cleanup ()
1158+ if got := countEventType (sub .Events , "user_message" ); got != 1 {
1159+ t .Fatalf ("user_message count = %d (types %v), want 1" , got , eventTypes (sub .Events ))
1160+ }
1161+ for _ , event := range sub .Events {
1162+ if event .Type != "user_message" {
1163+ continue
1164+ }
1165+ ref , ok := event .Payload ["message_ref" ].(map [string ]any )
1166+ if ! ok || ref ["message_id" ] != "msg-9" {
1167+ t .Fatalf ("user_message message_ref = %#v, want inline ref" , event .Payload ["message_ref" ])
1168+ }
1169+ }
1170+ }
1171+
1172+ // A webui edit_resend echo that carries identity is forwarded (so the ref
1173+ // binds), but its base_message_ref must not seed a second rebased — the
1174+ // truncation was already seeded from the command's accept-time payloads.
1175+ func TestWebuiEditResendIdentityEchoSeedsNoSecondRebased (t * testing.T ) {
1176+ m := NewManager ()
1177+ ref := testBaseMessageRef ()
1178+ m .StartChatCommand (conversationTestAgentID , "run-1" , "conv-1" , "/workspace" , "client-1" , []map [string ]any {
1179+ {"type" : StreamEventRebased , "base_message_ref" : ref , "reason" : "edit_resend" },
1180+ {"type" : "user_message" , "message" : "edited prompt" , "base_message_ref" : ref , "reason" : "edit_resend" },
1181+ })
1182+ m .ingestChatControl (conversationTestAgentID , "run-1" , startedControl ("run-1" , "conv-1" ))
1183+ identityEcho , _ := json .Marshal (map [string ]any {
1184+ "message" : "edited prompt" ,
1185+ "message_ref" : testNewMessageRef (),
1186+ "base_message_ref" : ref ,
1187+ "reason" : "edit_resend" ,
1188+ })
1189+ m .ingestChatEvent (conversationTestAgentID , "run-1" , & gatewayv2.ChatEvent {
1190+ Type : gatewayv2 .ChatEvent_USER_MESSAGE , ConversationId : "conv-1" , Data : string (identityEcho ),
1191+ })
1192+ m .ingestChatEvent (conversationTestAgentID , "run-1" , tokenEvent ("conv-1" , "reply" ))
1193+
1194+ sub := m .SubscribeConversationStream (conversationTestAgentID , "conv-1" , 0 , "" )
1195+ defer sub .Cleanup ()
1196+ if got := countEventType (sub .Events , StreamEventRebased ); got != 1 {
1197+ t .Fatalf ("rebased count = %d (types %v), want 1" , got , eventTypes (sub .Events ))
1198+ }
1199+ if got := countEventType (sub .Events , "user_message" ); got != 2 {
1200+ t .Fatalf ("user_message count = %d (types %v), want 2 (seed + forwarded identity echo)" , got , eventTypes (sub .Events ))
1201+ }
1202+ }
0 commit comments