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 { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { ConversationSelector } from "@/components/conversation-selector";
|
import { ConversationSelector } from "@/components/conversation-selector";
|
||||||
import { VolumeMeter } from "@/components/volume-meter";
|
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 { CreateAgentModal } from "@/components/create-agent-modal";
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
@@ -1015,6 +1015,9 @@ export default function VoiceAssistantScreen() {
|
|||||||
// Realtime mode always routes to orchestrator (handled by realtime audio)
|
// Realtime mode always routes to orchestrator (handled by realtime audio)
|
||||||
ws.sendUserMessage(userInput);
|
ws.sendUserMessage(userInput);
|
||||||
} else if (viewMode === "agent" && activeAgentId) {
|
} else if (viewMode === "agent" && activeAgentId) {
|
||||||
|
// Generate unique message ID for deduplication
|
||||||
|
const messageId = generateMessageId();
|
||||||
|
|
||||||
// Optimistically add user message to stream
|
// Optimistically add user message to stream
|
||||||
setAgentStreamState((prev) => {
|
setAgentStreamState((prev) => {
|
||||||
const currentStream = prev.get(activeAgentId) || [];
|
const currentStream = prev.get(activeAgentId) || [];
|
||||||
@@ -1025,6 +1028,7 @@ export default function VoiceAssistantScreen() {
|
|||||||
update: {
|
update: {
|
||||||
sessionUpdate: "user_message_chunk",
|
sessionUpdate: "user_message_chunk",
|
||||||
content: { type: "text", text: userInput },
|
content: { type: "text", text: userInput },
|
||||||
|
messageId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
new Date()
|
new Date()
|
||||||
@@ -1034,13 +1038,14 @@ export default function VoiceAssistantScreen() {
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send to agent
|
// Send to agent with messageId
|
||||||
const message: WSInboundMessage = {
|
const message: WSInboundMessage = {
|
||||||
type: "session",
|
type: "session",
|
||||||
message: {
|
message: {
|
||||||
type: "send_agent_message",
|
type: "send_agent_message",
|
||||||
agentId: activeAgentId,
|
agentId: activeAgentId,
|
||||||
text: userInput,
|
text: userInput,
|
||||||
|
messageId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ws.send(message);
|
ws.send(message);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { View, Text, ScrollView, Pressable } from 'react-native';
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { StyleSheet } from 'react-native-unistyles';
|
import { StyleSheet } from 'react-native-unistyles';
|
||||||
import type { AgentStatus } from '@server/server/acp/types';
|
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';
|
import type { StreamItem } from '@/types/stream';
|
||||||
|
|
||||||
export interface AgentStreamViewProps {
|
export interface AgentStreamViewProps {
|
||||||
@@ -17,106 +17,6 @@ export interface AgentStreamViewProps {
|
|||||||
streamItems: StreamItem[];
|
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({
|
export function AgentStreamView({
|
||||||
agentId,
|
agentId,
|
||||||
agent,
|
agent,
|
||||||
@@ -149,10 +49,10 @@ export function AgentStreamView({
|
|||||||
switch (item.kind) {
|
switch (item.kind) {
|
||||||
case 'user_message':
|
case 'user_message':
|
||||||
return (
|
return (
|
||||||
<UserMessageCard
|
<UserMessage
|
||||||
key={item.id}
|
key={item.id}
|
||||||
text={item.text}
|
message={item.text}
|
||||||
timestamp={item.timestamp}
|
timestamp={item.timestamp.getTime()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,23 +67,32 @@ export function AgentStreamView({
|
|||||||
|
|
||||||
case 'thought':
|
case 'thought':
|
||||||
return (
|
return (
|
||||||
<ThoughtCard
|
<ActivityLog
|
||||||
key={item.id}
|
key={item.id}
|
||||||
text={item.text}
|
type="info"
|
||||||
timestamp={item.timestamp}
|
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 (
|
return (
|
||||||
<ToolCallCard
|
<ToolCall
|
||||||
key={item.id}
|
key={item.id}
|
||||||
title={item.title}
|
toolName={item.title}
|
||||||
status={item.status}
|
args={item.rawInput}
|
||||||
toolKind={item.toolKind}
|
result={item.rawOutput}
|
||||||
timestamp={item.timestamp}
|
status={toolStatus}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case 'plan':
|
case 'plan':
|
||||||
// TODO: Render plan component
|
// TODO: Render plan component
|
||||||
@@ -209,164 +118,6 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.background,
|
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: {
|
scrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
|||||||
@@ -1,9 +1,51 @@
|
|||||||
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
||||||
|
|
||||||
// Simple ID generator that works in React Native
|
/**
|
||||||
let idCounter = 0;
|
* Simple hash function for deterministic ID generation
|
||||||
function generateId(): string {
|
* Uses a basic string hash algorithm for consistency across runs
|
||||||
return `stream_${Date.now()}_${idCounter++}`;
|
*/
|
||||||
|
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)
|
* Parsed notification types (internal representation before converting to StreamItem)
|
||||||
*/
|
*/
|
||||||
type ParsedNotification =
|
type ParsedNotification =
|
||||||
| { kind: 'user_message_chunk'; text: string }
|
| { kind: 'user_message_chunk'; text: string; messageId?: string }
|
||||||
| { kind: 'agent_message_chunk'; text: string }
|
| { kind: 'agent_message_chunk'; text: string }
|
||||||
| { kind: 'agent_thought_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[] }
|
| { 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 {
|
return {
|
||||||
kind: 'user_message_chunk',
|
kind: 'user_message_chunk',
|
||||||
text: update.content?.text || '',
|
text: update.content?.text || '',
|
||||||
|
messageId: update.messageId,
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'agent_message_chunk':
|
case 'agent_message_chunk':
|
||||||
@@ -201,9 +244,16 @@ export function reduceStreamUpdate(
|
|||||||
|
|
||||||
// User message chunks - always create new message (they're complete from server)
|
// User message chunks - always create new message (they're complete from server)
|
||||||
if (parsed.kind === 'user_message_chunk') {
|
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, {
|
return [...state, {
|
||||||
kind: 'user_message',
|
kind: 'user_message',
|
||||||
id: generateId(),
|
id,
|
||||||
text: parsed.text,
|
text: parsed.text,
|
||||||
timestamp,
|
timestamp,
|
||||||
}];
|
}];
|
||||||
@@ -222,10 +272,11 @@ export function reduceStreamUpdate(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// Create new message
|
// Create new message with deterministic ID
|
||||||
|
const id = deriveAssistantMessageId(state);
|
||||||
return [...state, {
|
return [...state, {
|
||||||
kind: 'assistant_message',
|
kind: 'assistant_message',
|
||||||
id: generateId(),
|
id,
|
||||||
text: parsed.text,
|
text: parsed.text,
|
||||||
timestamp,
|
timestamp,
|
||||||
}];
|
}];
|
||||||
@@ -244,10 +295,11 @@ export function reduceStreamUpdate(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// Create new thought
|
// Create new thought with deterministic ID
|
||||||
|
const id = deriveThoughtId(state);
|
||||||
return [...state, {
|
return [...state, {
|
||||||
kind: 'thought',
|
kind: 'thought',
|
||||||
id: generateId(),
|
id,
|
||||||
text: parsed.text,
|
text: parsed.text,
|
||||||
timestamp,
|
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') {
|
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, {
|
return [...state, {
|
||||||
kind: 'tool_call',
|
kind: 'tool_call',
|
||||||
id: parsed.toolCallId,
|
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') {
|
if (parsed.kind === 'plan') {
|
||||||
const withoutPlan = state.filter(item => item.kind !== 'plan');
|
const withoutPlan = state.filter(item => item.kind !== 'plan');
|
||||||
return [...withoutPlan, {
|
return [...withoutPlan, {
|
||||||
kind: 'plan',
|
kind: 'plan',
|
||||||
id: generateId(),
|
id: 'plan_latest',
|
||||||
entries: parsed.entries as any,
|
entries: parsed.entries as any,
|
||||||
timestamp,
|
timestamp,
|
||||||
}];
|
}];
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const SendAgentMessageSchema = z.object({
|
|||||||
type: z.literal("send_agent_message"),
|
type: z.literal("send_agent_message"),
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
|
messageId: z.string().optional(), // Client-provided ID for deduplication
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SendAgentAudioSchema = z.object({
|
export const SendAgentAudioSchema = z.object({
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export class Session {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "send_agent_message":
|
case "send_agent_message":
|
||||||
await this.handleSendAgentMessage(msg.agentId, msg.text);
|
await this.handleSendAgentMessage(msg.agentId, msg.text, msg.messageId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "send_agent_audio":
|
case "send_agent_audio":
|
||||||
@@ -442,7 +442,7 @@ export class Session {
|
|||||||
/**
|
/**
|
||||||
* Handle text message to agent
|
* Handle text message to agent
|
||||||
*/
|
*/
|
||||||
private async handleSendAgentMessage(agentId: string, text: string): Promise<void> {
|
private async handleSendAgentMessage(agentId: string, text: string, messageId?: string): Promise<void> {
|
||||||
console.log(
|
console.log(
|
||||||
`[Session ${this.clientId}] Sending text to agent ${agentId}: ${text.substring(0, 50)}...`
|
`[Session ${this.clientId}] Sending text to agent ${agentId}: ${text.substring(0, 50)}...`
|
||||||
);
|
);
|
||||||
@@ -463,6 +463,7 @@ export class Session {
|
|||||||
type: "text",
|
type: "text",
|
||||||
text: text,
|
text: text,
|
||||||
},
|
},
|
||||||
|
...(messageId ? { messageId } : {}),
|
||||||
},
|
},
|
||||||
} as any,
|
} as any,
|
||||||
},
|
},
|
||||||
@@ -660,9 +661,27 @@ export class Session {
|
|||||||
// Get live agents with session modes
|
// Get live agents with session modes
|
||||||
const agents = this.agentManager?.listAgents() || [];
|
const agents = this.agentManager?.listAgents() || [];
|
||||||
|
|
||||||
// Subscribe to all existing agents (in case of reconnection)
|
// Subscribe to all existing agents and send their history (in case of reconnection)
|
||||||
for (const agent of agents) {
|
for (const agent of agents) {
|
||||||
this.subscribeToAgent(agent.id);
|
this.subscribeToAgent(agent.id);
|
||||||
|
|
||||||
|
// Get historical updates from AgentManager
|
||||||
|
const history = this.agentManager.getAgentUpdates(agent.id);
|
||||||
|
|
||||||
|
// Send each historical update to client
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Sending ${history.length} historical updates for agent ${agent.id}`
|
||||||
|
);
|
||||||
|
for (const update of history) {
|
||||||
|
this.emit({
|
||||||
|
type: "agent_update",
|
||||||
|
payload: {
|
||||||
|
agentId: update.agentId,
|
||||||
|
timestamp: update.timestamp.toISOString(),
|
||||||
|
notification: update.notification,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get live commands from terminal manager
|
// Get live commands from terminal manager
|
||||||
|
|||||||
Reference in New Issue
Block a user