akseljoonas HF Staff commited on
Commit
d95f6cc
·
verified ·
1 Parent(s): 18509d0

Fix message disappearing and duplicate rendering bugs

Browse files

Fixes two related bugs in message handling:

1. **Message disappearing/reappearing**: Polling was overwriting SDK streaming state with stale backend state. Now only applies poll updates when backend has more messages.

2. **Duplicate message rendering**: Each poll created new message IDs, causing React to render duplicates. Now preserves existing message IDs across conversions.

Changes:
- Add poll update guard to prevent overwriting streaming state
- Preserve message IDs when converting backend messages to UI format
- Pass existing UI messages to llmMessagesToUIMessages() for ID reuse

create-pr.sh ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ # Colors for output
5
+ GREEN='\033[0;32m'
6
+ BLUE='\033[0;34m'
7
+ RED='\033[0;31m'
8
+ NC='\033[0m' # No Color
9
+
10
+ # Check arguments
11
+ if [ $# -lt 1 ]; then
12
+ echo -e "${RED}Usage: ./create-pr.sh \"PR Title\" [\"Optional description\"]${NC}"
13
+ echo ""
14
+ echo "Example:"
15
+ echo " ./create-pr.sh \"Fix authentication bug\" \"This fixes the dev mode auth issue\""
16
+ exit 1
17
+ fi
18
+
19
+ TITLE="$1"
20
+ DESCRIPTION="${2:-}"
21
+
22
+ # Get current branch
23
+ BRANCH=$(git rev-parse --abbrev-ref HEAD)
24
+
25
+ if [ "$BRANCH" = "main" ]; then
26
+ echo -e "${RED}Error: You're on the main branch. Please create a feature branch first.${NC}"
27
+ exit 1
28
+ fi
29
+
30
+ echo -e "${BLUE}Creating PR for branch: ${GREEN}$BRANCH${NC}"
31
+ echo -e "${BLUE}Title: ${GREEN}$TITLE${NC}"
32
+
33
+ # Get HF_TOKEN from .env
34
+ if [ ! -f .env ]; then
35
+ echo -e "${RED}Error: .env file not found${NC}"
36
+ exit 1
37
+ fi
38
+
39
+ HF_TOKEN=$(grep HF_TOKEN .env | cut -d '=' -f2)
40
+
41
+ if [ -z "$HF_TOKEN" ]; then
42
+ echo -e "${RED}Error: HF_TOKEN not found in .env${NC}"
43
+ exit 1
44
+ fi
45
+
46
+ # Get list of changed files
47
+ echo -e "${BLUE}Detecting changed files...${NC}"
48
+ CHANGED_FILES=$(git diff --name-only main.."$BRANCH")
49
+
50
+ if [ -z "$CHANGED_FILES" ]; then
51
+ echo -e "${RED}Error: No changes detected between main and $BRANCH${NC}"
52
+ exit 1
53
+ fi
54
+
55
+ echo -e "${BLUE}Changed files:${NC}"
56
+ echo "$CHANGED_FILES" | while read -r file; do
57
+ echo -e " ${GREEN}$file${NC}"
58
+ done
59
+
60
+ # Create PR using HuggingFace API with actual file operations
61
+ echo -e "${BLUE}Creating pull request with file changes...${NC}"
62
+
63
+ PR_URL=$(HF_TOKEN="$HF_TOKEN" uv run python - <<EOF
64
+ from huggingface_hub import HfApi, CommitOperationAdd
65
+ import os
66
+ import sys
67
+
68
+ api = HfApi(token=os.environ.get('HF_TOKEN'))
69
+
70
+ # Get changed files from stdin
71
+ changed_files = """$CHANGED_FILES""".strip().split('\n')
72
+
73
+ operations = []
74
+ for file_path in changed_files:
75
+ file_path = file_path.strip()
76
+ if not file_path:
77
+ continue
78
+
79
+ try:
80
+ with open(file_path, 'rb') as f:
81
+ operations.append(
82
+ CommitOperationAdd(
83
+ path_in_repo=file_path,
84
+ path_or_fileobj=f.read()
85
+ )
86
+ )
87
+ except FileNotFoundError:
88
+ print(f"Warning: File {file_path} not found, skipping", file=sys.stderr)
89
+ continue
90
+
91
+ if not operations:
92
+ print("Error: No valid file operations", file=sys.stderr)
93
+ sys.exit(1)
94
+
95
+ description = """$DESCRIPTION"""
96
+ commit_message = """$TITLE"""
97
+
98
+ # Create PR with actual file changes
99
+ try:
100
+ result = api.create_commit(
101
+ repo_id='smolagents/ml-agent',
102
+ repo_type='space',
103
+ commit_message=commit_message,
104
+ commit_description=description if description.strip() else f"Changes from branch $BRANCH",
105
+ operations=operations,
106
+ create_pr=True,
107
+ )
108
+ print(result.pr_url)
109
+ except Exception as e:
110
+ print(f"Error creating PR: {e}", file=sys.stderr)
111
+ import traceback
112
+ traceback.print_exc(file=sys.stderr)
113
+ sys.exit(1)
114
+ EOF
115
+ )
116
+
117
+ if [ $? -ne 0 ]; then
118
+ echo -e "${RED}Failed to create PR${NC}"
119
+ exit 1
120
+ fi
121
+
122
+ echo -e "${GREEN}✓ PR created successfully!${NC}"
123
+ echo -e "${GREEN} $PR_URL${NC}"
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -360,7 +360,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
360
  if (msgsRes.ok) {
361
  const data = await msgsRes.json();
362
  if (cancelled || !Array.isArray(data) || data.length === 0) return;
363
- const uiMsgs = llmMessagesToUIMessages(data, pendingIds);
364
  if (uiMsgs.length > 0) {
365
  chat.setMessages(uiMsgs);
366
  saveMessages(sessionId, uiMsgs);
@@ -481,7 +481,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
481
  // Final hydration to get the complete message state
482
  const result = await hydrateMessages();
483
  if (result) {
484
- const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds);
485
  if (uiMsgs.length > 0) {
486
  chat.setMessages(uiMsgs);
487
  saveMessages(sessionId, uiMsgs);
@@ -495,7 +495,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
495
  stopReconnect();
496
  const result = await hydrateMessages();
497
  if (result) {
498
- const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds);
499
  if (uiMsgs.length > 0) {
500
  chat.setMessages(uiMsgs);
501
  saveMessages(sessionId, uiMsgs);
@@ -519,7 +519,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
519
  if (!result) return;
520
 
521
  const { data, pendingIds, info } = result;
522
- const uiMsgs = llmMessagesToUIMessages(data, pendingIds);
523
  if (uiMsgs.length > 0) {
524
  chat.setMessages(uiMsgs);
525
  saveMessages(sessionId, uiMsgs);
@@ -542,11 +542,14 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
542
  pollTimerRef.current = setInterval(async () => {
543
  const fresh = await hydrateMessages();
544
  if (!fresh) return;
545
- const msgs = llmMessagesToUIMessages(fresh.data, fresh.pendingIds);
546
- if (msgs.length > 0) {
 
 
547
  chat.setMessages(msgs);
548
  saveMessages(sessionId, msgs);
549
- }
 
550
  // If backend stopped processing, clean up
551
  if (fresh.info && !fresh.info.is_processing) {
552
  updateSession(sessionId, { isProcessing: false });
@@ -570,7 +573,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
570
  if (chat.messages.length !== prevLenRef.current) {
571
  prevLenRef.current = chat.messages.length;
572
  saveMessages(sessionId, chat.messages);
573
- }
574
  }, [sessionId, chat.messages]);
575
 
576
  // -- Undo last turn (REST call + client-side message removal) -----------
 
360
  if (msgsRes.ok) {
361
  const data = await msgsRes.json();
362
  if (cancelled || !Array.isArray(data) || data.length === 0) return;
363
+ const uiMsgs = llmMessagesToUIMessages(data, pendingIds, chatActionsRef.current.messages);
364
  if (uiMsgs.length > 0) {
365
  chat.setMessages(uiMsgs);
366
  saveMessages(sessionId, uiMsgs);
 
481
  // Final hydration to get the complete message state
482
  const result = await hydrateMessages();
483
  if (result) {
484
+ const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
485
  if (uiMsgs.length > 0) {
486
  chat.setMessages(uiMsgs);
487
  saveMessages(sessionId, uiMsgs);
 
495
  stopReconnect();
496
  const result = await hydrateMessages();
497
  if (result) {
498
+ const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
499
  if (uiMsgs.length > 0) {
500
  chat.setMessages(uiMsgs);
501
  saveMessages(sessionId, uiMsgs);
 
519
  if (!result) return;
520
 
521
  const { data, pendingIds, info } = result;
522
+ const uiMsgs = llmMessagesToUIMessages(data, pendingIds, chatActionsRef.current.messages);
523
  if (uiMsgs.length > 0) {
524
  chat.setMessages(uiMsgs);
525
  saveMessages(sessionId, uiMsgs);
 
542
  pollTimerRef.current = setInterval(async () => {
543
  const fresh = await hydrateMessages();
544
  if (!fresh) return;
545
+ const msgs = llmMessagesToUIMessages(fresh.data, fresh.pendingIds, chatActionsRef.current.messages);
546
+
547
+ const currentCount = chatActionsRef.current.messages.length;
548
+ if (msgs.length > currentCount || currentCount === 0) {
549
  chat.setMessages(msgs);
550
  saveMessages(sessionId, msgs);
551
+ }
552
+
553
  // If backend stopped processing, clean up
554
  if (fresh.info && !fresh.info.is_processing) {
555
  updateSession(sessionId, { isProcessing: false });
 
573
  if (chat.messages.length !== prevLenRef.current) {
574
  prevLenRef.current = chat.messages.length;
575
  saveMessages(sessionId, chat.messages);
576
+ }
577
  }, [sessionId, chat.messages]);
578
 
579
  // -- Undo last turn (REST call + client-side message removal) -----------
frontend/src/lib/chat-message-store.ts CHANGED
@@ -38,7 +38,8 @@ function writeAll(map: MessagesMap): void {
38
 
39
  export function loadMessages(sessionId: string): UIMessage[] {
40
  const map = readAll();
41
- return map[sessionId] ?? [];
 
42
  }
43
 
44
  export function saveMessages(sessionId: string, messages: UIMessage[]): void {
 
38
 
39
  export function loadMessages(sessionId: string): UIMessage[] {
40
  const map = readAll();
41
+ const messages = map[sessionId] ?? [];
42
+ return messages;
43
  }
44
 
45
  export function saveMessages(sessionId: string, messages: UIMessage[]): void {
frontend/src/lib/convert-llm-messages.ts CHANGED
@@ -16,19 +16,24 @@ interface LLMMessage {
16
  name?: string | null;
17
  }
18
 
19
- let idCounter = 0;
 
 
20
  function nextId(): string {
21
- return `msg-${Date.now()}-${++idCounter}`;
22
  }
23
 
24
  /**
25
  * @param pendingApprovalIds - Set of tool_call_ids that are waiting for approval.
26
  * When provided, matching tool calls without results will get state
27
  * 'approval-requested' instead of 'input-available'.
 
 
28
  */
29
  export function llmMessagesToUIMessages(
30
  messages: LLMMessage[],
31
  pendingApprovalIds?: Set<string>,
 
32
  ): UIMessage[] {
33
  // Build a map of tool_call_id -> tool result for pairing
34
  const toolResults = new Map<string, { output: string; isError: boolean }>();
@@ -43,13 +48,22 @@ export function llmMessagesToUIMessages(
43
 
44
  const uiMessages: UIMessage[] = [];
45
 
 
 
 
 
 
 
 
46
  for (const msg of messages) {
47
  if (msg.role === 'system') continue;
48
  if (msg.role === 'tool') continue; // handled via tool_calls pairing
49
 
50
  if (msg.role === 'user') {
 
 
51
  uiMessages.push({
52
- id: nextId(),
53
  role: 'user',
54
  parts: [{ type: 'text', text: msg.content || '' }],
55
  });
@@ -109,8 +123,11 @@ export function llmMessagesToUIMessages(
109
  if (prev && prev.role === 'assistant') {
110
  prev.parts.push(...parts);
111
  } else {
 
 
 
112
  uiMessages.push({
113
- id: nextId(),
114
  role: 'assistant',
115
  parts,
116
  });
 
16
  name?: string | null;
17
  }
18
 
19
+ // Generate stable IDs based on message position to prevent duplicate renders
20
+ // when the same message is re-converted multiple times (e.g., during polling)
21
+ let uiMessageCounter = 0;
22
  function nextId(): string {
23
+ return `msg-${++uiMessageCounter}`;
24
  }
25
 
26
  /**
27
  * @param pendingApprovalIds - Set of tool_call_ids that are waiting for approval.
28
  * When provided, matching tool calls without results will get state
29
  * 'approval-requested' instead of 'input-available'.
30
+ * @param existingUIMessages - Current UI messages to preserve IDs when content matches.
31
+ * This prevents React from re-rendering messages with new IDs during polling.
32
  */
33
  export function llmMessagesToUIMessages(
34
  messages: LLMMessage[],
35
  pendingApprovalIds?: Set<string>,
36
+ existingUIMessages?: UIMessage[],
37
  ): UIMessage[] {
38
  // Build a map of tool_call_id -> tool result for pairing
39
  const toolResults = new Map<string, { output: string; isError: boolean }>();
 
48
 
49
  const uiMessages: UIMessage[] = [];
50
 
51
+ // Helper to get existing message ID at a given position if roles match
52
+ const getExistingId = (index: number, role: 'user' | 'assistant'): string | null => {
53
+ if (!existingUIMessages || index >= existingUIMessages.length) return null;
54
+ const existing = existingUIMessages[index];
55
+ return existing.role === role ? existing.id : null;
56
+ };
57
+
58
  for (const msg of messages) {
59
  if (msg.role === 'system') continue;
60
  if (msg.role === 'tool') continue; // handled via tool_calls pairing
61
 
62
  if (msg.role === 'user') {
63
+ // Try to reuse existing ID if the message at this position matches
64
+ const existingId = getExistingId(uiMessages.length, 'user');
65
  uiMessages.push({
66
+ id: existingId || nextId(),
67
  role: 'user',
68
  parts: [{ type: 'text', text: msg.content || '' }],
69
  });
 
123
  if (prev && prev.role === 'assistant') {
124
  prev.parts.push(...parts);
125
  } else {
126
+ // Try to reuse existing ID if the message at this position matches
127
+ const existingId = getExistingId(uiMessages.length, 'assistant');
128
+ const newId = existingId || nextId();
129
  uiMessages.push({
130
+ id: newId,
131
  role: 'assistant',
132
  parts,
133
  });