Skip to content

Commit f4f90da

Browse files
authored
Merge pull request Stack-Cairn#239 from Stack-Cairn/fix/edit-resend-message-ref
fix(chat): propagate user message refs so edit-resend can anchor remotely
2 parents 330dc81 + 474b4a5 commit f4f90da

14 files changed

Lines changed: 651 additions & 25 deletions

File tree

crates/agent-gateway/internal/session/conversation_ingress.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,23 @@ func (m *Manager) ingestChatEvent(agentID, requestID string, event *gatewayv2.Ch
5555
if event.GetType() == gatewayv2.ChatEvent_USER_MESSAGE {
5656
if record := s.runs[agentScopedKey(agentID, runID)]; record != nil && record.userMessageSeeded {
5757
messageID, _ := payload["message_id"].(string)
58+
if strings.TrimSpace(messageID) == "" {
59+
// The full stable ref (edit-resend rebase anchoring) carries
60+
// the same id; either field proves the echo has new identity.
61+
if ref, ok := payload["message_ref"].(map[string]any); ok {
62+
refMessageID, _ := ref["message_id"].(string)
63+
messageID = refMessageID
64+
}
65+
}
5866
if strings.TrimSpace(messageID) == "" || record.userMessageIdentityForwarded {
5967
// The gateway already appended this run's user_message at accept
6068
// time. A plain or replayed agent echo adds no new identity.
6169
return
6270
}
6371
// Forward one authoritative desktop echo carrying the stable message
64-
// id. WebUI upserts it into the run's single user slot, so this enriches
65-
// identity without creating a second bubble.
72+
// identity (message_id, plus message_ref so a follow-up edit-resend
73+
// can anchor its rebase). WebUI upserts it into the run's single user
74+
// slot, so this enriches identity without creating a second bubble.
6675
record.userMessageIdentityForwarded = true
6776
}
6877
}
@@ -418,6 +427,7 @@ func (s *conversationStreamStore) bindPendingRunLocked(
418427
stream, pending.runID, pending.clientRequestID, pending.seeded, now,
419428
)
420429
record.userMessageSeeded = seededPayloadsIncludeUserMessage(pending.seeded)
430+
record.rebaseSeeded = seededPayloadsIncludeRebased(pending.seeded)
421431
s.updateChatCommandDedupeLocked(
422432
pending.agentID,
423433
pending.clientRequestID,

crates/agent-gateway/internal/session/conversation_stream.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,11 @@ type chatRunRecord struct {
186186
// queuedInGUI marks commands the desktop app parked in its prompt queue;
187187
// the startup watchdog must leave them alone.
188188
queuedInGUI bool
189-
// rebaseSeeded marks runs whose rebased event was already appended from
190-
// the agent's ref-bearing user_message, so a reconnect replay of the same
191-
// event cannot seed a second truncation.
189+
// rebaseSeeded marks runs whose rebased event was already appended — from
190+
// the agent's ref-bearing user_message (GUI-local edits) or from the
191+
// gateway-seeded payloads of a webui edit_resend command — so neither a
192+
// reconnect replay nor the identity-forwarded desktop echo can seed a
193+
// second truncation.
192194
rebaseSeeded bool
193195
// lostInferred marks a run whose terminal was inferred from missing
194196
// liveness reports (desktop_run_lost and friends) rather than delivered by
@@ -1035,6 +1037,7 @@ func (m *Manager) StartChatCommand(
10351037
s.markRunQueuedLocked(stream, runID, clientRequestID, now)
10361038
acceptedSeq := s.appendSeededPayloadsLocked(stream, runID, clientRequestID, seededPayloads, now)
10371039
record.userMessageSeeded = seededPayloadsIncludeUserMessage(seededPayloads)
1040+
record.rebaseSeeded = seededPayloadsIncludeRebased(seededPayloads)
10381041
start := ChatCommandStart{
10391042
AgentID: agentID,
10401043
RunID: runID,
@@ -1063,6 +1066,7 @@ func (s *conversationStreamStore) flushDeferredSeedsLocked(
10631066
record.deferredSeeds = nil
10641067
s.appendSeededPayloadsLocked(stream, runID, record.clientRequestID, seeds, now)
10651068
record.userMessageSeeded = seededPayloadsIncludeUserMessage(seeds)
1069+
record.rebaseSeeded = seededPayloadsIncludeRebased(seeds)
10661070
}
10671071

10681072
func (s *conversationStreamStore) appendSeededPayloadsLocked(
@@ -1097,6 +1101,19 @@ func (s *conversationStreamStore) appendSeededPayloadsLocked(
10971101
return acceptedSeq
10981102
}
10991103

1104+
// seededPayloadsIncludeRebased mirrors seededPayloadsIncludeUserMessage for
1105+
// the webui edit_resend truncation seed: marking rebaseSeeded at accept time
1106+
// keeps the identity-forwarded desktop echo (which still carries the same
1107+
// base_message_ref) from appending a second rebased to the log.
1108+
func seededPayloadsIncludeRebased(seededPayloads []map[string]any) bool {
1109+
for _, payload := range seededPayloads {
1110+
if eventType, _ := payload["type"].(string); eventType == StreamEventRebased {
1111+
return true
1112+
}
1113+
}
1114+
return false
1115+
}
1116+
11001117
func seededPayloadsIncludeUserMessage(seededPayloads []map[string]any) bool {
11011118
for _, payload := range seededPayloads {
11021119
if eventType, _ := payload["type"].(string); eventType == "user_message" {

crates/agent-gateway/internal/session/conversation_stream_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

crates/agent-gateway/web/src/lib/chat/transcript/historyAlignment.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,11 @@ function replaceAll(entries: ChatEntry[], turns: Turn[], historyTurns: HistoryTu
335335
// "Covered" requires the reply, not just the prompt: a settled turn whose
336336
// window twin is still user-only (the desktop's post-run flush races the
337337
// fetch) keeps rendering its streamed content (protectLaggedSettledTurns).
338-
function alignReplace(params: { turns: Turn[]; entries: ChatEntry[] }): AlignResult {
338+
function alignReplace(params: {
339+
turns: Turn[];
340+
entries: ChatEntry[];
341+
rebaseReconcile?: boolean;
342+
}): AlignResult {
339343
let historyTurns = groupHistoryEntriesIntoTurns(params.entries);
340344

341345
// Trim persisted echoes of active exchanges. Ref-anchored first (covers
@@ -419,7 +423,14 @@ function alignReplace(params: { turns: Turn[]; entries: ChatEntry[] }): AlignRes
419423
reflessSettled.length,
420424
Math.max(0, historyUserCount - refMatchedCount),
421425
);
422-
const keptRefless = new Set(reflessSettled.slice(coveredRefless));
426+
// Rebase reconciliation: the server authoritatively deleted a settled
427+
// suffix (edit-resend truncation whose anchor this client missed), so
428+
// uncovered ref-less settled turns are stale edit versions, not
429+
// persistence-lagged exchanges — none survive. Genuinely lagged replies
430+
// were already captured by protectLaggedSettledTurns above.
431+
const keptRefless = new Set(
432+
params.rebaseReconcile === true ? [] : reflessSettled.slice(coveredRefless),
433+
);
423434

424435
const turns = params.turns.flatMap((turn) => {
425436
const protectedTurn = protectedTurns.get(turn);
@@ -557,9 +568,18 @@ export function alignHistory(params: {
557568
turns: Turn[];
558569
entries: ChatEntry[];
559570
mode: HistoryApplyMode;
571+
// Set after a rebased event whose truncation anchor was missing locally:
572+
// the server deleted a settled suffix this client still renders, so the
573+
// replace must not keep uncovered ref-less settled turns (only meaningful
574+
// with mode "replace").
575+
rebaseReconcile?: boolean;
560576
}): AlignResult {
561577
if (params.mode === "replace") {
562-
return alignReplace({ turns: params.turns, entries: params.entries });
578+
return alignReplace({
579+
turns: params.turns,
580+
entries: params.entries,
581+
rebaseReconcile: params.rebaseReconcile,
582+
});
563583
}
564584
return alignEnrich(params);
565585
}

0 commit comments

Comments
 (0)