mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
POST "https://api.anthropic.com/v1/messages": 429 Too Many Requests {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CWHC7FgSsHDSb24VtNd8n"}
This commit is contained in:
@@ -247,6 +247,7 @@ describe('agent-capabilities', () => {
|
||||
'supportsUsageStats',
|
||||
'supportsBatchMode',
|
||||
'supportsStreaming',
|
||||
'supportsStreamJsonInput',
|
||||
'supportsResultMessages',
|
||||
'supportsModelSelection',
|
||||
'requiresPromptToStart',
|
||||
|
||||
@@ -47,6 +47,9 @@ vi.mock('lucide-react', () => ({
|
||||
Trash2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<svg data-testid="trash2-icon" className={className} style={style} />
|
||||
),
|
||||
FileText: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<svg data-testid="file-text-icon" className={className} style={style} />
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock navigator.clipboard at module level
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
import React from 'react';
|
||||
|
||||
// Create a mock icon component factory
|
||||
const createMockIcon = (name: string) => {
|
||||
const MockIcon = function({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||
return React.createElement('svg', {
|
||||
'data-testid': `${name.toLowerCase().replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')}-icon`,
|
||||
className,
|
||||
style,
|
||||
});
|
||||
};
|
||||
MockIcon.displayName = name;
|
||||
return MockIcon;
|
||||
};
|
||||
|
||||
// Global mock for lucide-react using Proxy to auto-generate mock icons
|
||||
// This ensures any icon import works without explicitly listing every icon
|
||||
vi.mock('lucide-react', () => {
|
||||
const iconCache = new Map<string, ReturnType<typeof createMockIcon>>();
|
||||
|
||||
return new Proxy({}, {
|
||||
get(_target, prop: string) {
|
||||
// Ignore internal properties
|
||||
if (prop === '__esModule' || prop === 'default' || typeof prop === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Return cached icon or create new one
|
||||
if (!iconCache.has(prop)) {
|
||||
iconCache.set(prop, createMockIcon(prop));
|
||||
}
|
||||
return iconCache.get(prop);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock window.matchMedia for components that use media queries
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
|
||||
@@ -34,6 +34,12 @@ vi.mock('../../../web/mobile/constants', () => ({
|
||||
send: [20],
|
||||
interrupt: [50],
|
||||
},
|
||||
GESTURE_THRESHOLDS: {
|
||||
swipeDistance: 50,
|
||||
swipeTime: 300,
|
||||
pullToRefresh: 80,
|
||||
longPress: 500,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../web/utils/logger', () => ({
|
||||
|
||||
@@ -350,6 +350,9 @@ ${message}`;
|
||||
|
||||
// Spawn the moderator process in batch mode
|
||||
try {
|
||||
// Emit state change to show moderator is thinking
|
||||
groupChatEmitters.emitStateChange?.(groupChatId, 'moderator-thinking');
|
||||
|
||||
processManager.spawn({
|
||||
sessionId,
|
||||
toolType: chat.moderatorAgentId,
|
||||
@@ -362,6 +365,7 @@ ${message}`;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[GroupChatRouter] Failed to spawn moderator for ${groupChatId}:`, error);
|
||||
groupChatEmitters.emitStateChange?.(groupChatId, 'idle');
|
||||
throw new Error(`Failed to spawn moderator: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -531,6 +535,9 @@ Please respond to this request.${readOnly ? ' Remember: READ-ONLY mode is active
|
||||
const customEnvVars = getCustomEnvVarsCallback?.(participant.agentId);
|
||||
|
||||
try {
|
||||
// Emit participant state change to show this participant is working
|
||||
groupChatEmitters.emitParticipantState?.(groupChatId, participantName, 'working');
|
||||
|
||||
processManager.spawn({
|
||||
sessionId,
|
||||
toolType: participant.agentId,
|
||||
@@ -731,6 +738,9 @@ Review the agent responses above. Either:
|
||||
// Spawn the synthesis process
|
||||
try {
|
||||
console.log(`[GroupChatRouter] Spawning moderator synthesis for ${groupChatId}`);
|
||||
// Emit state change to show moderator is thinking (synthesizing)
|
||||
groupChatEmitters.emitStateChange?.(groupChatId, 'moderator-thinking');
|
||||
|
||||
processManager.spawn({
|
||||
sessionId,
|
||||
toolType: chat.moderatorAgentId,
|
||||
@@ -743,5 +753,6 @@ Review the agent responses above. Either:
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[GroupChatRouter] Failed to spawn moderator synthesis for ${groupChatId}:`, error);
|
||||
groupChatEmitters.emitStateChange?.(groupChatId, 'idle');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2017,6 +2017,10 @@ function setupProcessListeners() {
|
||||
if (participantExitInfo) {
|
||||
const { groupChatId, participantName } = participantExitInfo;
|
||||
logger.debug(`[GroupChat] Participant exit: ${participantName} (groupChatId=${groupChatId})`, 'ProcessListener', { sessionId });
|
||||
|
||||
// Emit participant state change to show this participant is done working
|
||||
groupChatEmitters.emitParticipantState?.(groupChatId, participantName, 'idle');
|
||||
|
||||
// Route the buffered output now that process is complete
|
||||
const bufferedOutput = groupChatOutputBuffers.get(sessionId);
|
||||
if (bufferedOutput) {
|
||||
|
||||
@@ -78,6 +78,11 @@ export interface ModeratorUsage {
|
||||
tokenCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Participant state for tracking individual agent working status.
|
||||
*/
|
||||
export type ParticipantState = 'idle' | 'working';
|
||||
|
||||
/**
|
||||
* Module-level object to store emitter functions after initialization.
|
||||
* These can be used by other modules to emit messages and state changes.
|
||||
@@ -88,6 +93,7 @@ export const groupChatEmitters: {
|
||||
emitParticipantsChanged?: (groupChatId: string, participants: GroupChatParticipant[]) => void;
|
||||
emitModeratorUsage?: (groupChatId: string, usage: ModeratorUsage) => void;
|
||||
emitHistoryEntry?: (groupChatId: string, entry: GroupChatHistoryEntry) => void;
|
||||
emitParticipantState?: (groupChatId: string, participantName: string, state: ParticipantState) => void;
|
||||
} = {};
|
||||
|
||||
// Helper to create handler options with consistent context
|
||||
@@ -479,5 +485,16 @@ export function registerGroupChatHandlers(deps: GroupChatHandlerDependencies): v
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Emit a participant state change event to the renderer.
|
||||
* Called when a participant starts or finishes working.
|
||||
*/
|
||||
groupChatEmitters.emitParticipantState = (groupChatId: string, participantName: string, state: ParticipantState): void => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('groupChat:participantState', groupChatId, participantName, state);
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('Registered Group Chat IPC handlers', LOG_CONTEXT);
|
||||
}
|
||||
|
||||
@@ -1006,6 +1006,11 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
ipcRenderer.on('groupChat:historyEntry', handler);
|
||||
return () => ipcRenderer.removeListener('groupChat:historyEntry', handler);
|
||||
},
|
||||
onParticipantState: (callback: (groupChatId: string, participantName: string, state: 'idle' | 'working') => void) => {
|
||||
const handler = (_: any, groupChatId: string, participantName: string, state: 'idle' | 'working') => callback(groupChatId, participantName, state);
|
||||
ipcRenderer.on('groupChat:participantState', handler);
|
||||
return () => ipcRenderer.removeListener('groupChat:participantState', handler);
|
||||
},
|
||||
},
|
||||
|
||||
// Leaderboard API (runmaestro.ai integration)
|
||||
|
||||
@@ -224,6 +224,8 @@ export default function MaestroConsole() {
|
||||
const [groupChatRightTab, setGroupChatRightTab] = useState<GroupChatRightTab>('participants');
|
||||
const [groupChatParticipantColors, setGroupChatParticipantColors] = useState<Record<string, string>>({});
|
||||
const [moderatorUsage, setModeratorUsage] = useState<{ contextUsage: number; totalCost: number; tokenCount: number } | null>(null);
|
||||
// Track per-participant working state (participantName -> 'idle' | 'working')
|
||||
const [participantStates, setParticipantStates] = useState<Map<string, 'idle' | 'working'>>(new Map());
|
||||
|
||||
// --- BATCHED SESSION UPDATES (reduces React re-renders during AI streaming) ---
|
||||
const batchedUpdater = useBatchedSessionUpdates(setSessions);
|
||||
@@ -1587,11 +1589,22 @@ export default function MaestroConsole() {
|
||||
}
|
||||
});
|
||||
|
||||
const unsubParticipantState = window.maestro.groupChat.onParticipantState?.((id, participantName, state) => {
|
||||
if (id === activeGroupChatId) {
|
||||
setParticipantStates(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(participantName, state);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubMessage();
|
||||
unsubState();
|
||||
unsubParticipants();
|
||||
unsubModeratorUsage?.();
|
||||
unsubParticipantState?.();
|
||||
};
|
||||
}, [activeGroupChatId]);
|
||||
|
||||
@@ -2733,6 +2746,7 @@ export default function MaestroConsole() {
|
||||
setActiveGroupChatId(null);
|
||||
setGroupChatMessages([]);
|
||||
setGroupChatState('idle');
|
||||
setParticipantStates(new Map());
|
||||
}, []);
|
||||
|
||||
// Handle right panel tab change with persistence
|
||||
@@ -2786,6 +2800,7 @@ export default function MaestroConsole() {
|
||||
setActiveGroupChatId(null);
|
||||
setGroupChatMessages([]);
|
||||
setGroupChatState('idle');
|
||||
setParticipantStates(new Map());
|
||||
|
||||
// Set the session as active
|
||||
setActiveSessionId(session.id);
|
||||
@@ -5869,11 +5884,7 @@ export default function MaestroConsole() {
|
||||
theme={theme}
|
||||
groupChatId={activeGroupChatId}
|
||||
participants={groupChats.find(c => c.id === activeGroupChatId)?.participants || []}
|
||||
participantStates={new Map(
|
||||
sessions
|
||||
.filter(s => groupChats.find(c => c.id === activeGroupChatId)?.participants.some(p => p.sessionId === s.id))
|
||||
.map(s => [s.id, s.state])
|
||||
)}
|
||||
participantStates={participantStates}
|
||||
participantSessionPaths={new Map(
|
||||
sessions
|
||||
.filter(s => groupChats.find(c => c.id === activeGroupChatId)?.participants.some(p => p.sessionId === s.id))
|
||||
|
||||
@@ -26,7 +26,8 @@ interface GroupChatRightPanelProps {
|
||||
theme: Theme;
|
||||
groupChatId: string;
|
||||
participants: GroupChatParticipant[];
|
||||
participantStates: Map<string, SessionState>;
|
||||
/** Map of participant name to their working state */
|
||||
participantStates: Map<string, 'idle' | 'working'>;
|
||||
/** Map of participant sessionId to their project root path (for color preferences) */
|
||||
participantSessionPaths?: Map<string, string>;
|
||||
isOpen: boolean;
|
||||
@@ -303,15 +304,20 @@ export function GroupChatRightPanel({
|
||||
Ask the moderator to add agents.
|
||||
</div>
|
||||
) : (
|
||||
sortedParticipants.map((participant) => (
|
||||
<ParticipantCard
|
||||
key={participant.sessionId}
|
||||
theme={theme}
|
||||
participant={participant}
|
||||
state={participantStates.get(participant.sessionId) || 'idle'}
|
||||
color={participantColors[participant.name]}
|
||||
/>
|
||||
))
|
||||
sortedParticipants.map((participant) => {
|
||||
// Convert 'working' state to 'busy' for SessionState compatibility
|
||||
const workState = participantStates.get(participant.name);
|
||||
const sessionState = workState === 'working' ? 'busy' : 'idle';
|
||||
return (
|
||||
<ParticipantCard
|
||||
key={participant.sessionId}
|
||||
theme={theme}
|
||||
participant={participant}
|
||||
state={sessionState}
|
||||
color={participantColors[participant.name]}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -42,16 +42,17 @@ export function ParticipantCard({
|
||||
}: ParticipantCardProps): JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Prefer agent's session ID (clean GUID) over internal process session ID
|
||||
const displaySessionId = participant.agentSessionId || participant.sessionId;
|
||||
// Use agent's session ID (clean GUID) when available, otherwise show pending
|
||||
const agentSessionId = participant.agentSessionId;
|
||||
const isPending = !agentSessionId;
|
||||
|
||||
const copySessionId = useCallback(() => {
|
||||
if (displaySessionId) {
|
||||
navigator.clipboard.writeText(displaySessionId);
|
||||
if (agentSessionId) {
|
||||
navigator.clipboard.writeText(agentSessionId);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [displaySessionId]);
|
||||
}, [agentSessionId]);
|
||||
|
||||
const getStatusColor = (): string => {
|
||||
switch (state) {
|
||||
@@ -108,7 +109,18 @@ export function ParticipantCard({
|
||||
</span>
|
||||
</div>
|
||||
{/* Session ID pill - top right */}
|
||||
{displaySessionId && (
|
||||
{isPending ? (
|
||||
<span
|
||||
className="text-[10px] px-2 py-0.5 rounded-full shrink-0 italic"
|
||||
style={{
|
||||
backgroundColor: `${theme.colors.textDim}20`,
|
||||
color: theme.colors.textDim,
|
||||
border: `1px solid ${theme.colors.textDim}40`,
|
||||
}}
|
||||
>
|
||||
pending
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={copySessionId}
|
||||
className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full hover:opacity-80 transition-opacity cursor-pointer shrink-0"
|
||||
@@ -117,10 +129,10 @@ export function ParticipantCard({
|
||||
color: theme.colors.accent,
|
||||
border: `1px solid ${theme.colors.accent}40`,
|
||||
}}
|
||||
title={`Session: ${displaySessionId}\nClick to copy`}
|
||||
title={`Session: ${agentSessionId}\nClick to copy`}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{displaySessionId.slice(0, 8)}
|
||||
{agentSessionId.slice(0, 8)}
|
||||
</span>
|
||||
{copied ? (
|
||||
<Check className="w-2.5 h-2.5" />
|
||||
|
||||
@@ -50,7 +50,7 @@ interface LogItemProps {
|
||||
onSetDeleteConfirmLogId: (logId: string | null) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement>;
|
||||
// Other callbacks
|
||||
setLightboxImage: (image: string | null, contextImages?: string[]) => void;
|
||||
setLightboxImage: (image: string | null, contextImages?: string[], source?: 'staged' | 'history') => void;
|
||||
copyToClipboard: (text: string) => void;
|
||||
speakText?: (text: string, logId: string) => void;
|
||||
stopSpeaking?: () => void;
|
||||
@@ -369,7 +369,7 @@ const LogItemComponent = memo(({
|
||||
src={img}
|
||||
className="h-20 rounded border cursor-zoom-in shrink-0"
|
||||
style={{ objectFit: 'contain', maxWidth: '200px' }}
|
||||
onClick={() => setLightboxImage(img, log.images)}
|
||||
onClick={() => setLightboxImage(img, log.images, 'history')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -784,7 +784,7 @@ interface TerminalOutputProps {
|
||||
setOutputSearchOpen: (open: boolean) => void;
|
||||
setOutputSearchQuery: (query: string) => void;
|
||||
setActiveFocus: (focus: string) => void;
|
||||
setLightboxImage: (image: string | null, contextImages?: string[]) => void;
|
||||
setLightboxImage: (image: string | null, contextImages?: string[], source?: 'staged' | 'history') => void;
|
||||
inputRef: React.RefObject<HTMLTextAreaElement>;
|
||||
logsEndRef: React.RefObject<HTMLDivElement>;
|
||||
maxOutputLines: number;
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist', 'release'],
|
||||
exclude: ['node_modules', 'dist', 'release', 'src/__tests__/integration/**'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'text-summary', 'json', 'html'],
|
||||
|
||||
Reference in New Issue
Block a user