Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Fix message disappearing and duplicate rendering bugs
Browse filesFixes 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 +123 -0
- frontend/src/hooks/useAgentChat.ts +11 -8
- frontend/src/lib/chat-message-store.ts +2 -1
- frontend/src/lib/convert-llm-messages.ts +21 -4
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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 20 |
function nextId(): string {
|
| 21 |
-
return `msg-${
|
| 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:
|
| 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 |
});
|