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:
Pedram Amini
2025-12-19 21:00:24 -06:00
parent 8de57c3a48
commit e767b091a8
13 changed files with 138 additions and 27 deletions

View File

@@ -247,6 +247,7 @@ describe('agent-capabilities', () => {
'supportsUsageStats',
'supportsBatchMode',
'supportsStreaming',
'supportsStreamJsonInput',
'supportsResultMessages',
'supportsModelSelection',
'requiresPromptToStart',

View File

@@ -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

View File

@@ -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', {

View File

@@ -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', () => ({

View File

@@ -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');
}
}

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -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>
) : (

View File

@@ -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" />

View File

@@ -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;

View File

@@ -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'],