refactor: deduplicate user messages and sync agent history on reconnection

This commit is contained in:
Mohamed Boudra
2025-10-22 22:38:15 +02:00
parent b86e5fbb3f
commit 70bdbd74eb
5 changed files with 142 additions and 289 deletions

View File

@@ -48,7 +48,7 @@ import { ActiveProcesses } from "@/components/active-processes";
import { AgentStreamView } from "@/components/agent-stream-view";
import { ConversationSelector } from "@/components/conversation-selector";
import { VolumeMeter } from "@/components/volume-meter";
import { reduceStreamUpdate, type StreamItem } from "@/types/stream";
import { reduceStreamUpdate, generateMessageId, type StreamItem } from "@/types/stream";
import { CreateAgentModal } from "@/components/create-agent-modal";
import {
Settings,
@@ -1015,6 +1015,9 @@ export default function VoiceAssistantScreen() {
// Realtime mode always routes to orchestrator (handled by realtime audio)
ws.sendUserMessage(userInput);
} else if (viewMode === "agent" && activeAgentId) {
// Generate unique message ID for deduplication
const messageId = generateMessageId();
// Optimistically add user message to stream
setAgentStreamState((prev) => {
const currentStream = prev.get(activeAgentId) || [];
@@ -1025,6 +1028,7 @@ export default function VoiceAssistantScreen() {
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text: userInput },
messageId,
},
},
new Date()
@@ -1034,13 +1038,14 @@ export default function VoiceAssistantScreen() {
return updated;
});
// Send to agent
// Send to agent with messageId
const message: WSInboundMessage = {
type: "session",
message: {
type: "send_agent_message",
agentId: activeAgentId,
text: userInput,
messageId,
},
};
ws.send(message);

View File

@@ -3,7 +3,7 @@ import { View, Text, ScrollView, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { StyleSheet } from 'react-native-unistyles';
import type { AgentStatus } from '@server/server/acp/types';
import { AssistantMessage } from './message';
import { AssistantMessage, UserMessage, ActivityLog, ToolCall } from './message';
import type { StreamItem } from '@/types/stream';
export interface AgentStreamViewProps {
@@ -17,106 +17,6 @@ export interface AgentStreamViewProps {
streamItems: StreamItem[];
}
function getStatusColor(status: AgentStatus): string {
switch (status) {
case 'initializing':
return '#f59e0b';
case 'ready':
return '#3b82f6';
case 'processing':
return '#fbbf24';
case 'completed':
return '#22c55e';
case 'failed':
return '#ef4444';
case 'killed':
return '#9ca3af';
default:
return '#9ca3af';
}
}
function getStatusIcon(status: AgentStatus): string {
switch (status) {
case 'initializing':
case 'processing':
return '⏳';
case 'ready':
case 'completed':
return '✓';
case 'failed':
case 'killed':
return '✗';
default:
return '•';
}
}
function formatTimestamp(date: Date): string {
// Handle invalid dates gracefully
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
return '--:--:--';
}
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
}).format(date);
}
function UserMessageCard({ text, timestamp }: { text: string; timestamp: Date }) {
return (
<View style={stylesheet.userMessageCard}>
<Text style={stylesheet.timestamp}>
{formatTimestamp(timestamp)}
</Text>
<Text style={stylesheet.userMessageLabel}>You</Text>
<Text style={stylesheet.userMessageText}>{text}</Text>
</View>
);
}
function ThoughtCard({ text, timestamp }: { text: string; timestamp: Date }) {
return (
<View style={stylesheet.thoughtCard}>
<Text style={stylesheet.timestamp}>
{formatTimestamp(timestamp)}
</Text>
<Text style={stylesheet.thoughtLabel}>Thinking</Text>
<Text style={stylesheet.thoughtText}>{text}</Text>
</View>
);
}
function ToolCallCard({
title,
status,
toolKind,
timestamp
}: {
title: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
toolKind?: string;
timestamp: Date;
}) {
return (
<View style={stylesheet.toolCallCard}>
<Text style={stylesheet.timestamp}>
{formatTimestamp(timestamp)}
</Text>
<View style={stylesheet.toolCallHeader}>
<Text style={stylesheet.toolCallLabel}>{toolKind || 'Tool'}</Text>
<Text style={[stylesheet.toolCallStatus, { color: getStatusColor(status as any) }]}>
{status}
</Text>
</View>
<Text style={stylesheet.toolCallTitle}>{title}</Text>
</View>
);
}
export function AgentStreamView({
agentId,
agent,
@@ -149,10 +49,10 @@ export function AgentStreamView({
switch (item.kind) {
case 'user_message':
return (
<UserMessageCard
<UserMessage
key={item.id}
text={item.text}
timestamp={item.timestamp}
message={item.text}
timestamp={item.timestamp.getTime()}
/>
);
@@ -167,23 +67,32 @@ export function AgentStreamView({
case 'thought':
return (
<ThoughtCard
<ActivityLog
key={item.id}
text={item.text}
timestamp={item.timestamp}
type="info"
message={item.text}
timestamp={item.timestamp.getTime()}
/>
);
case 'tool_call':
case 'tool_call': {
// Map status: pending/in_progress -> executing, completed -> completed, failed -> failed
const toolStatus = item.status === 'pending' || item.status === 'in_progress'
? 'executing' as const
: item.status === 'completed'
? 'completed' as const
: 'failed' as const;
return (
<ToolCallCard
<ToolCall
key={item.id}
title={item.title}
status={item.status}
toolKind={item.toolKind}
timestamp={item.timestamp}
toolName={item.title}
args={item.rawInput}
result={item.rawOutput}
status={toolStatus}
/>
);
}
case 'plan':
// TODO: Render plan component
@@ -209,164 +118,6 @@ const stylesheet = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.background,
},
userMessageCard: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
borderLeftWidth: 3,
borderLeftColor: theme.colors.primary,
},
userMessageLabel: {
color: theme.colors.primary,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
},
userMessageText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
timestamp: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
thoughtCard: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
borderLeftWidth: 3,
borderLeftColor: theme.colors.palette.purple[500],
},
thoughtLabel: {
color: theme.colors.palette.purple[500],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[1],
},
thoughtText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
fontStyle: 'italic',
},
toolCallCard: {
backgroundColor: theme.colors.card,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
borderLeftWidth: 3,
borderLeftColor: theme.colors.palette.blue[500],
},
toolCallHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: theme.spacing[1],
},
toolCallLabel: {
color: theme.colors.palette.blue[500],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
toolCallStatus: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
toolCallTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
header: {
backgroundColor: theme.colors.card,
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
backButton: {
backgroundColor: theme.colors.muted,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[4],
},
backButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
agentInfoRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
},
agentIdRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
agentLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
agentId: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontFamily: "monospace",
},
statusBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
statusIcon: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
statusText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
createdText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
},
controlButtons: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
cancelButton: {
flex: 1,
backgroundColor: theme.colors.palette.orange[600],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
killButton: {
flex: 1,
backgroundColor: theme.colors.destructive,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
controlButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
scrollView: {
flex: 1,
paddingHorizontal: theme.spacing[4],

View File

@@ -1,9 +1,51 @@
import type { SessionNotification } from '@agentclientprotocol/sdk';
// Simple ID generator that works in React Native
let idCounter = 0;
function generateId(): string {
return `stream_${Date.now()}_${idCounter++}`;
/**
* Simple hash function for deterministic ID generation
* Uses a basic string hash algorithm for consistency across runs
*/
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(36);
}
/**
* Generate a simple unique ID (timestamp + random)
*/
export function generateMessageId(): string {
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}
/**
* Derive deterministic ID for user message based on timestamp and content
* If messageId is provided from server, use that instead
*/
function deriveUserMessageId(text: string, timestamp: Date, messageId?: string): string {
if (messageId) {
return messageId;
}
return `user_${timestamp.getTime()}_${simpleHash(text)}`;
}
/**
* Derive deterministic ID for assistant message based on index in state
*/
function deriveAssistantMessageId(state: StreamItem[]): string {
const count = state.filter(s => s.kind === 'assistant_message').length;
return `assistant_${count}`;
}
/**
* Derive deterministic ID for thought based on index in state
*/
function deriveThoughtId(state: StreamItem[]): string {
const count = state.filter(s => s.kind === 'thought').length;
return `thought_${count}`;
}
/**
@@ -86,7 +128,7 @@ export interface ArtifactItem {
* Parsed notification types (internal representation before converting to StreamItem)
*/
type ParsedNotification =
| { kind: 'user_message_chunk'; text: string }
| { kind: 'user_message_chunk'; text: string; messageId?: string }
| { kind: 'agent_message_chunk'; text: string }
| { kind: 'agent_thought_chunk'; text: string }
| { kind: 'tool_call'; toolCallId: string; title: string; status?: string; toolKind?: string; rawInput?: any; rawOutput?: any; content?: any[]; locations?: any[] }
@@ -113,6 +155,7 @@ function parseNotification(notification: SessionNotification | any): ParsedNotif
return {
kind: 'user_message_chunk',
text: update.content?.text || '',
messageId: update.messageId,
};
case 'agent_message_chunk':
@@ -201,9 +244,16 @@ export function reduceStreamUpdate(
// User message chunks - always create new message (they're complete from server)
if (parsed.kind === 'user_message_chunk') {
const id = deriveUserMessageId(parsed.text, timestamp, parsed.messageId);
// Idempotency check - if this exact message already exists, don't add it again
if (state.some(item => item.id === id)) {
return state;
}
return [...state, {
kind: 'user_message',
id: generateId(),
id,
text: parsed.text,
timestamp,
}];
@@ -222,10 +272,11 @@ export function reduceStreamUpdate(
},
];
}
// Create new message
// Create new message with deterministic ID
const id = deriveAssistantMessageId(state);
return [...state, {
kind: 'assistant_message',
id: generateId(),
id,
text: parsed.text,
timestamp,
}];
@@ -244,10 +295,11 @@ export function reduceStreamUpdate(
},
];
}
// Create new thought
// Create new thought with deterministic ID
const id = deriveThoughtId(state);
return [...state, {
kind: 'thought',
id: generateId(),
id,
text: parsed.text,
timestamp,
}];
@@ -273,8 +325,33 @@ export function reduceStreamUpdate(
});
}
// New tool call
// New tool call - check if exists first (server may send multiple notifications for same tool)
if (parsed.kind === 'tool_call') {
const existingIndex = state.findIndex(
item => item.kind === 'tool_call' && item.toolCallId === parsed.toolCallId
);
if (existingIndex >= 0) {
// Update existing tool call
return state.map((item, idx) => {
if (idx === existingIndex && item.kind === 'tool_call') {
return {
...item,
title: parsed.title,
status: (parsed.status as any) || item.status,
toolKind: (parsed.toolKind as any) || item.toolKind,
rawInput: parsed.rawInput ? { ...item.rawInput, ...parsed.rawInput } : item.rawInput,
rawOutput: parsed.rawOutput ? { ...item.rawOutput, ...parsed.rawOutput } : item.rawOutput,
content: parsed.content || item.content,
locations: parsed.locations || item.locations,
timestamp,
};
}
return item;
});
}
// Create new tool call
return [...state, {
kind: 'tool_call',
id: parsed.toolCallId,
@@ -290,12 +367,12 @@ export function reduceStreamUpdate(
}];
}
// Plan - replace existing or create new
// Plan - replace existing or create new (uses constant ID since only one plan exists)
if (parsed.kind === 'plan') {
const withoutPlan = state.filter(item => item.kind !== 'plan');
return [...withoutPlan, {
kind: 'plan',
id: generateId(),
id: 'plan_latest',
entries: parsed.entries as any,
timestamp,
}];