mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor: deduplicate user messages and sync agent history on reconnection
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
}];
|
||||
|
||||
Reference in New Issue
Block a user