mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
Merge branch 'copilot/precious-seahorse'
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
pruneSessionUsage,
|
||||
pruneSessionRequestUsage,
|
||||
pruneTurnEventLogs,
|
||||
purgeCompletedActivity,
|
||||
type SessionActivityMap,
|
||||
type SessionUsageMap,
|
||||
type SessionRequestUsageMap,
|
||||
@@ -149,6 +150,7 @@ export default function App() {
|
||||
const [sessionRequestUsage, setSessionRequestUsage] = useState<SessionRequestUsageMap>({});
|
||||
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
|
||||
const [activeSubagents, setActiveSubagents] = useState<ActiveSubagentMap>({});
|
||||
const activityPurgeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>();
|
||||
@@ -226,12 +228,35 @@ export default function App() {
|
||||
setSessionRequestUsage((current) => applyAssistantUsageEvent(current, event));
|
||||
setTurnEventLogs((current) => applyTurnEventLog(current, event));
|
||||
setActiveSubagents((current) => applySubagentEvent(current, event));
|
||||
|
||||
// Schedule purge of completed activity labels after grace period
|
||||
if (event.kind === 'status' && event.status === 'idle') {
|
||||
const existing = activityPurgeTimers.current.get(event.sessionId);
|
||||
if (existing) clearTimeout(existing);
|
||||
activityPurgeTimers.current.set(
|
||||
event.sessionId,
|
||||
setTimeout(() => {
|
||||
setSessionActivities((current) => purgeCompletedActivity(current, event.sessionId));
|
||||
activityPurgeTimers.current.delete(event.sessionId);
|
||||
}, 1500),
|
||||
);
|
||||
}
|
||||
// Cancel pending purge if a new run starts
|
||||
if (event.kind === 'status' && event.status === 'running') {
|
||||
const existing = activityPurgeTimers.current.get(event.sessionId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
activityPurgeTimers.current.delete(event.sessionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
offWorkspace();
|
||||
offSessionEvent();
|
||||
for (const timer of activityPurgeTimers.current.values()) clearTimeout(timer);
|
||||
activityPurgeTimers.current.clear();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
@@ -705,6 +730,7 @@ export default function App() {
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
session={selectedSession}
|
||||
sessionUsage={usageForSession}
|
||||
sessionActivity={activityForSession}
|
||||
activeSubagents={subagentsForSession}
|
||||
terminalOpen={bottomPanelOpen && bottomPanelTab === 'terminal'}
|
||||
terminalRunning={terminalRunning}
|
||||
|
||||
@@ -19,7 +19,8 @@ import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment';
|
||||
import type { SessionUsageState } from '@renderer/lib/sessionActivity';
|
||||
import type { SessionUsageState, SessionActivityState } from '@renderer/lib/sessionActivity';
|
||||
import { summarizeSessionActivity } from '@renderer/lib/sessionActivity';
|
||||
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
||||
import {
|
||||
findModel,
|
||||
@@ -55,6 +56,7 @@ interface ChatPaneProps {
|
||||
mcpProbingServerIds?: string[];
|
||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||
sessionUsage?: SessionUsageState;
|
||||
sessionActivity?: SessionActivityState;
|
||||
activeSubagents?: ReadonlyArray<ActiveSubagent>;
|
||||
terminalOpen?: boolean;
|
||||
terminalRunning?: boolean;
|
||||
@@ -92,6 +94,7 @@ export function ChatPane({
|
||||
mcpProbingServerIds,
|
||||
runtimeTools,
|
||||
sessionUsage,
|
||||
sessionActivity,
|
||||
activeSubagents,
|
||||
terminalOpen,
|
||||
terminalRunning,
|
||||
@@ -133,20 +136,44 @@ export function ChatPane({
|
||||
const items: DisplayItem[] = [];
|
||||
let pendingThinking: ChatMessageRecord[] = [];
|
||||
let lastUserMessageId: string | undefined;
|
||||
const messages = session.messages;
|
||||
const busy = session.status === 'running';
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
const isLast = i === messages.length - 1;
|
||||
|
||||
for (const message of session.messages) {
|
||||
if (message.messageKind === 'thinking') {
|
||||
pendingThinking.push(message);
|
||||
} else {
|
||||
if (pendingThinking.length > 0) {
|
||||
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
|
||||
items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt });
|
||||
pendingThinking = [];
|
||||
}
|
||||
items.push({ type: 'message', message });
|
||||
if (message.role === 'user') {
|
||||
lastUserMessageId = message.id;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Optimistic thinking classification: fold a pending assistant
|
||||
// message into the current thinking group when it immediately follows
|
||||
// thinking messages during an active turn. This prevents the brief
|
||||
// flash of content that occurs before a message-reclassified event
|
||||
// arrives. Only the very last message qualifies — earlier messages
|
||||
// have already been classified definitively.
|
||||
if (
|
||||
busy
|
||||
&& isLast
|
||||
&& pendingThinking.length > 0
|
||||
&& message.role === 'assistant'
|
||||
&& message.pending
|
||||
&& !message.messageKind
|
||||
) {
|
||||
pendingThinking.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pendingThinking.length > 0) {
|
||||
const run = lastUserMessageId ? runsByTrigger.get(lastUserMessageId) : undefined;
|
||||
items.push({ type: 'thinking-group', messages: pendingThinking, turnStartedAt: run?.startedAt });
|
||||
pendingThinking = [];
|
||||
}
|
||||
items.push({ type: 'message', message });
|
||||
if (message.role === 'user') {
|
||||
lastUserMessageId = message.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +183,7 @@ export function ChatPane({
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [session.messages, session.runs]);
|
||||
}, [session.messages, session.runs, session.status]);
|
||||
|
||||
const lastThinkingGroupIndex = useMemo(() => {
|
||||
for (let i = displayItems.length - 1; i >= 0; i--) {
|
||||
@@ -165,6 +192,19 @@ export function ChatPane({
|
||||
return -1;
|
||||
}, [displayItems]);
|
||||
|
||||
// A thinking group is active when the session is running AND the group
|
||||
// contains at least one pending message (currently streaming). This is
|
||||
// more robust than relying purely on array position, which can be stale
|
||||
// during reclassification gaps.
|
||||
const isThinkingGroupActive = useCallback(
|
||||
(group: { messages: ChatMessageRecord[] }, groupIndex: number): boolean => {
|
||||
if (!isSessionBusy) return false;
|
||||
if (groupIndex !== lastThinkingGroupIndex) return false;
|
||||
return group.messages.some((m) => m.pending);
|
||||
},
|
||||
[isSessionBusy, lastThinkingGroupIndex],
|
||||
);
|
||||
|
||||
const lastAssistantId = useMemo(() => {
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const m = session.messages[i];
|
||||
@@ -423,7 +463,15 @@ export function ChatPane({
|
||||
Awaiting your input
|
||||
</div>
|
||||
)}
|
||||
{isSessionBusy && !pendingApproval && !pendingUserInput && <span className="size-2 animate-pulse rounded-full bg-[var(--color-accent-sky)]" />}
|
||||
{isSessionBusy && !pendingApproval && !pendingUserInput && (() => {
|
||||
const label = summarizeSessionActivity(sessionActivity);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-accent-sky)]">
|
||||
<span className="size-2 animate-pulse rounded-full bg-[var(--color-accent-sky)]" />
|
||||
<span className="transition-opacity duration-200">{label ?? 'Working…'}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{session.status === 'error' && (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-status-error)]">
|
||||
<AlertCircle className="size-3.5" />
|
||||
@@ -471,12 +519,11 @@ export function ChatPane({
|
||||
<div className="space-y-1">
|
||||
{displayItems.map((item, itemIndex) => {
|
||||
if (item.type === 'thinking-group') {
|
||||
const isLastThinkingGroup = itemIndex === lastThinkingGroupIndex;
|
||||
return (
|
||||
<div key={`thinking-${item.messages[0].id}`} className="py-2">
|
||||
<ThinkingProcess
|
||||
messages={item.messages}
|
||||
isActive={isSessionBusy && isLastThinkingGroup}
|
||||
isActive={isThinkingGroupActive(item, itemIndex)}
|
||||
turnStartedAt={item.turnStartedAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bot, CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
|
||||
import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
|
||||
|
||||
const COMPLETION_GRACE_MS = 3000;
|
||||
|
||||
function formatElapsed(startedAt: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
@@ -37,9 +39,10 @@ function ElapsedTimer({ startedAt }: { startedAt: string }) {
|
||||
|
||||
interface SubagentActivityCardProps {
|
||||
subagent: ActiveSubagent;
|
||||
fading?: boolean;
|
||||
}
|
||||
|
||||
function SubagentActivityCard({ subagent }: SubagentActivityCardProps) {
|
||||
function SubagentActivityCard({ subagent, fading }: SubagentActivityCardProps) {
|
||||
const borderClass =
|
||||
subagent.status === 'running'
|
||||
? 'border-[var(--color-accent-sky)]/20'
|
||||
@@ -49,7 +52,7 @@ function SubagentActivityCard({ subagent }: SubagentActivityCardProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border bg-[var(--color-glass)] px-3 py-1.5 transition-all duration-200 ${borderClass}`}
|
||||
className={`flex items-center gap-2 rounded-lg border bg-[var(--color-glass)] px-3 py-1.5 transition-all duration-300 ${borderClass} ${fading ? 'opacity-0' : 'opacity-100'}`}
|
||||
role="status"
|
||||
aria-label={`Sub-agent ${subagent.name}: ${subagent.activityLabel}`}
|
||||
>
|
||||
@@ -73,16 +76,68 @@ interface SubagentActivityListProps {
|
||||
}
|
||||
|
||||
export function SubagentActivityList({ subagents }: SubagentActivityListProps) {
|
||||
// Track recently-completed subagent IDs so we can show them briefly
|
||||
const [recentlyDone, setRecentlyDone] = useState<Set<string>>(new Set());
|
||||
const [fading, setFading] = useState<Set<string>>(new Set());
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
for (const sub of subagents) {
|
||||
if (sub.status !== 'running' && !recentlyDone.has(sub.toolCallId) && !timersRef.current.has(sub.toolCallId)) {
|
||||
// Newly completed — track it
|
||||
setRecentlyDone((prev) => new Set(prev).add(sub.toolCallId));
|
||||
|
||||
const fadeTimer = setTimeout(() => {
|
||||
setFading((prev) => new Set(prev).add(sub.toolCallId));
|
||||
}, COMPLETION_GRACE_MS - 300);
|
||||
|
||||
const removeTimer = setTimeout(() => {
|
||||
setRecentlyDone((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(sub.toolCallId);
|
||||
return next;
|
||||
});
|
||||
setFading((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(sub.toolCallId);
|
||||
return next;
|
||||
});
|
||||
timersRef.current.delete(sub.toolCallId);
|
||||
}, COMPLETION_GRACE_MS);
|
||||
|
||||
timersRef.current.set(sub.toolCallId, removeTimer);
|
||||
// Store fade timer for cleanup
|
||||
timersRef.current.set(`fade-${sub.toolCallId}`, fadeTimer);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subagents]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (subagents.length === 0) return null;
|
||||
|
||||
// Only show running subagents in the chat stream
|
||||
const visible = subagents.filter((s) => s.status === 'running');
|
||||
// Show running subagents + recently-completed ones within the grace period
|
||||
const visible = subagents.filter(
|
||||
(s) => s.status === 'running' || recentlyDone.has(s.toolCallId),
|
||||
);
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-1" aria-label="Active sub-agents">
|
||||
{visible.map((subagent) => (
|
||||
<SubagentActivityCard key={subagent.toolCallId} subagent={subagent} />
|
||||
<SubagentActivityCard
|
||||
key={subagent.toolCallId}
|
||||
subagent={subagent}
|
||||
fading={fading.has(subagent.toolCallId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
interface ThinkingProcessProps {
|
||||
@@ -26,24 +27,22 @@ export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingP
|
||||
|
||||
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
||||
|
||||
const elapsed = useMemo(() => {
|
||||
if (!turnStartedAt || messages.length === 0) return undefined;
|
||||
const start = new Date(turnStartedAt).getTime();
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const end = isActive ? Date.now() : new Date(lastMessage.createdAt).getTime();
|
||||
const seconds = Math.max(0, Math.round((end - start) / 1000));
|
||||
if (seconds < 2) return undefined;
|
||||
return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
|
||||
}, [turnStartedAt, messages, isActive]);
|
||||
const elapsed = useElapsedTimer(
|
||||
messages.length > 0 ? turnStartedAt : undefined,
|
||||
isActive,
|
||||
);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stepCount = messages.length;
|
||||
// Don't count empty pending messages (optimistically folded in) toward step count
|
||||
const stepCount = messages.filter((m) => m.content).length;
|
||||
const summaryParts: string[] = [];
|
||||
if (elapsed) summaryParts.push(`${elapsed}`);
|
||||
summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`);
|
||||
if (stepCount > 0) {
|
||||
summaryParts.push(`${stepCount} ${stepCount === 1 ? 'step' : 'steps'}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="thinking-process-enter mb-2 overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60">
|
||||
@@ -87,6 +86,11 @@ export function ThinkingProcess({ messages, isActive, turnStartedAt }: ThinkingP
|
||||
function ThinkingStep({ message }: { message: ChatMessageRecord }) {
|
||||
const preview = useMemo(() => truncatePreview(message.content, 180), [message.content]);
|
||||
|
||||
// Pending message folded into thinking group with no content yet
|
||||
if (message.pending && !message.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 text-[12px] leading-relaxed">
|
||||
<span className="mt-0.5 shrink-0 text-[var(--color-text-muted)]">▸</span>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function formatElapsed(startMs: number): string {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return `${minutes}m ${remainder}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a live-ticking elapsed-time string while `active` is true.
|
||||
* Freezes the display when `active` becomes false.
|
||||
*/
|
||||
export function useElapsedTimer(startedAt: string | undefined, active: boolean): string | undefined {
|
||||
const startMs = startedAt ? new Date(startedAt).getTime() : undefined;
|
||||
|
||||
const [elapsed, setElapsed] = useState<string | undefined>(() => {
|
||||
if (!startMs) return undefined;
|
||||
return formatElapsed(startMs);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!startMs) {
|
||||
setElapsed(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always sync to current value immediately
|
||||
setElapsed(formatElapsed(startMs));
|
||||
|
||||
if (!active) return;
|
||||
|
||||
const id = setInterval(() => setElapsed(formatElapsed(startMs)), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [startMs, active]);
|
||||
|
||||
return elapsed;
|
||||
}
|
||||
@@ -166,12 +166,16 @@ function clearActiveSessionActivity(
|
||||
return current;
|
||||
}
|
||||
|
||||
// Transition active entries to 'completed' instead of removing them.
|
||||
// The UI layer uses a grace timer to eventually clear these.
|
||||
let changed = false;
|
||||
const nextSessionActivity = Object.fromEntries(
|
||||
Object.entries(sessionActivity).filter(([, activity]) => {
|
||||
const keep = !isAgentActivityActive(activity);
|
||||
changed ||= !keep;
|
||||
return keep;
|
||||
Object.entries(sessionActivity).map(([key, activity]) => {
|
||||
if (isAgentActivityActive(activity)) {
|
||||
changed = true;
|
||||
return [key, { ...activity, activityType: 'completed' as const }];
|
||||
}
|
||||
return [key, activity];
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -179,10 +183,6 @@ function clearActiveSessionActivity(
|
||||
return current;
|
||||
}
|
||||
|
||||
if (Object.keys(nextSessionActivity).length === 0) {
|
||||
return removeSessionActivity(current, sessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[sessionId]: nextSessionActivity,
|
||||
@@ -466,6 +466,60 @@ export function pruneSessionRequestUsage(
|
||||
|
||||
/* ── Formatting helpers ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Remove all completed activity entries for a session.
|
||||
* Called after the UI grace period to finish clearing stale labels.
|
||||
*/
|
||||
export function purgeCompletedActivity(
|
||||
current: SessionActivityMap,
|
||||
sessionId: string,
|
||||
): SessionActivityMap {
|
||||
const sessionActivity = current[sessionId];
|
||||
if (!sessionActivity) return current;
|
||||
|
||||
const nextSessionActivity = Object.fromEntries(
|
||||
Object.entries(sessionActivity).filter(([, a]) => !isAgentActivityCompleted(a)),
|
||||
);
|
||||
|
||||
if (Object.keys(nextSessionActivity).length === Object.keys(sessionActivity).length) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (Object.keys(nextSessionActivity).length === 0) {
|
||||
return removeSessionActivity(current, sessionId);
|
||||
}
|
||||
|
||||
return { ...current, [sessionId]: nextSessionActivity };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single summary label for the most relevant activity in a session.
|
||||
* Prefers active states (thinking → tool-calling → handoff) over completed.
|
||||
*/
|
||||
export function summarizeSessionActivity(
|
||||
sessionActivity: SessionActivityState | undefined,
|
||||
): string | undefined {
|
||||
if (!sessionActivity) return undefined;
|
||||
|
||||
const entries = Object.values(sessionActivity);
|
||||
if (entries.length === 0) return undefined;
|
||||
|
||||
// Prefer the first active entry by priority
|
||||
const active = entries.find((a) => a.activityType === 'thinking')
|
||||
?? entries.find((a) => a.activityType === 'tool-calling')
|
||||
?? entries.find((a) => a.activityType === 'handoff');
|
||||
|
||||
if (active) {
|
||||
return formatAgentActivityLabel(active);
|
||||
}
|
||||
|
||||
// If everything is completed, show that
|
||||
const completed = entries.find((a) => a.activityType === 'completed');
|
||||
if (completed) return 'Completed';
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
isAgentActivityCompleted,
|
||||
pruneSessionActivities,
|
||||
pruneSessionRequestUsage,
|
||||
purgeCompletedActivity,
|
||||
summarizeSessionActivity,
|
||||
type SessionActivityMap,
|
||||
type SessionRequestUsageMap,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
@@ -209,6 +211,7 @@ describe('session activity helpers', () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Active agents transition to 'completed' on idle (grace period before purge)
|
||||
expect(
|
||||
applySessionEventActivity(current, {
|
||||
sessionId: 'session-1',
|
||||
@@ -223,6 +226,11 @@ describe('session activity helpers', () => {
|
||||
agentName: 'Architect',
|
||||
activityType: 'completed',
|
||||
},
|
||||
reviewer: {
|
||||
agentId: 'reviewer',
|
||||
agentName: 'Reviewer',
|
||||
activityType: 'completed',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -243,6 +251,7 @@ describe('session activity helpers', () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Active agents transition to 'completed' on cancel (grace period before purge)
|
||||
expect(
|
||||
applySessionEventActivity(current, {
|
||||
sessionId: 'session-1',
|
||||
@@ -272,6 +281,11 @@ describe('session activity helpers', () => {
|
||||
agentName: 'Architect',
|
||||
activityType: 'completed',
|
||||
},
|
||||
reviewer: {
|
||||
agentId: 'reviewer',
|
||||
agentName: 'Reviewer',
|
||||
activityType: 'completed',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -392,6 +406,92 @@ describe('session activity helpers', () => {
|
||||
'session-2': current['session-2'],
|
||||
});
|
||||
});
|
||||
|
||||
test('purgeCompletedActivity removes completed entries after grace period', () => {
|
||||
const current: SessionActivityMap = {
|
||||
'session-1': {
|
||||
architect: {
|
||||
agentId: 'architect',
|
||||
agentName: 'Architect',
|
||||
activityType: 'completed',
|
||||
},
|
||||
reviewer: {
|
||||
agentId: 'reviewer',
|
||||
agentName: 'Reviewer',
|
||||
activityType: 'completed',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = purgeCompletedActivity(current, 'session-1');
|
||||
expect(result['session-1']).toBeUndefined();
|
||||
});
|
||||
|
||||
test('purgeCompletedActivity is a no-op when nothing is completed', () => {
|
||||
const current: SessionActivityMap = {
|
||||
'session-1': {
|
||||
architect: {
|
||||
agentId: 'architect',
|
||||
agentName: 'Architect',
|
||||
activityType: 'thinking',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(purgeCompletedActivity(current, 'session-1')).toBe(current);
|
||||
});
|
||||
|
||||
test('summarizeSessionActivity returns the most relevant label', () => {
|
||||
expect(summarizeSessionActivity(undefined)).toBeUndefined();
|
||||
expect(summarizeSessionActivity({})).toBeUndefined();
|
||||
|
||||
expect(
|
||||
summarizeSessionActivity({
|
||||
architect: {
|
||||
agentId: 'architect',
|
||||
agentName: 'Architect',
|
||||
activityType: 'thinking',
|
||||
},
|
||||
}),
|
||||
).toBe('Thinking…');
|
||||
|
||||
expect(
|
||||
summarizeSessionActivity({
|
||||
architect: {
|
||||
agentId: 'architect',
|
||||
agentName: 'Architect',
|
||||
activityType: 'tool-calling',
|
||||
toolName: 'bash',
|
||||
},
|
||||
}),
|
||||
).toBe('Using bash…');
|
||||
|
||||
// Thinking takes priority over tool-calling
|
||||
expect(
|
||||
summarizeSessionActivity({
|
||||
architect: {
|
||||
agentId: 'architect',
|
||||
agentName: 'Architect',
|
||||
activityType: 'thinking',
|
||||
},
|
||||
reviewer: {
|
||||
agentId: 'reviewer',
|
||||
agentName: 'Reviewer',
|
||||
activityType: 'tool-calling',
|
||||
toolName: 'read_file',
|
||||
},
|
||||
}),
|
||||
).toBe('Thinking…');
|
||||
|
||||
expect(
|
||||
summarizeSessionActivity({
|
||||
architect: {
|
||||
agentId: 'architect',
|
||||
agentName: 'Architect',
|
||||
activityType: 'completed',
|
||||
},
|
||||
}),
|
||||
).toBe('Completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant usage accumulator', () => {
|
||||
|
||||
Reference in New Issue
Block a user