diff --git a/apps/electron/src/renderer/atoms/agent-atoms.test.ts b/apps/electron/src/renderer/atoms/agent-atoms.test.ts new file mode 100644 index 000000000..8a92791be --- /dev/null +++ b/apps/electron/src/renderer/atoms/agent-atoms.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test' +import { createStore } from 'jotai/vanilla' +import type { AskUserRequest, ExitPlanModeRequest, PermissionRequest } from '@proma/shared' +import { + agentSessionIndicatorMapAtom, + agentStreamErrorsAtom, + agentStreamingStatesAtom, + allPendingAskUserRequestsAtom, + allPendingExitPlanRequestsAtom, + allPendingPermissionRequestsAtom, + unviewedCompletedSessionIdsAtom, + type AgentStreamState, +} from './agent-atoms' + +function runningState(overrides: Partial = {}): AgentStreamState { + return { + running: true, + content: '', + toolActivities: [], + startedAt: 1_000, + ...overrides, + } +} + +function permissionRequest(sessionId: string): PermissionRequest { + return { + requestId: `perm-${sessionId}`, + sessionId, + toolName: 'Bash', + toolInput: { command: 'bun test' }, + description: '运行测试', + dangerLevel: 'normal', + } +} + +function askUserRequest(sessionId: string): AskUserRequest { + return { + requestId: `ask-${sessionId}`, + sessionId, + questions: [{ question: '继续吗?', options: [] }], + toolInput: {}, + } +} + +function exitPlanRequest(sessionId: string): ExitPlanModeRequest { + return { + requestId: `plan-${sessionId}`, + sessionId, + toolInput: {}, + allowedPrompts: [], + } +} + +describe('agentSessionIndicatorMapAtom', () => { + test('given no session state when deriving indicators then idle sessions are omitted', () => { + const store = createStore() + + expect(store.get(agentSessionIndicatorMapAtom).has('idle-session')).toBe(false) + }) + + test('given running stream state when deriving indicators then session is running', () => { + const store = createStore() + store.set(agentStreamingStatesAtom, new Map([ + ['session-running', runningState()], + ])) + + expect(store.get(agentSessionIndicatorMapAtom).get('session-running')).toBe('running') + }) + + test('given unviewed completed session when deriving indicators then session is completed', () => { + const store = createStore() + store.set(unviewedCompletedSessionIdsAtom, new Set(['session-completed'])) + + expect(store.get(agentSessionIndicatorMapAtom).get('session-completed')).toBe('completed') + }) + + test('given stream error without running state when deriving indicators then session is error', () => { + const store = createStore() + store.set(agentStreamErrorsAtom, new Map([ + ['session-error', 'API 服务不可用'], + ])) + + expect(store.get(agentSessionIndicatorMapAtom).get('session-error')).toBe('error') + }) + + test('given running stream and stream error when deriving indicators then error overrides running', () => { + const store = createStore() + store.set(agentStreamingStatesAtom, new Map([ + ['session-error', runningState()], + ])) + store.set(agentStreamErrorsAtom, new Map([ + ['session-error', '网络已断开'], + ])) + + expect(store.get(agentSessionIndicatorMapAtom).get('session-error')).toBe('error') + }) + + test('given completed session and stream error when deriving indicators then error overrides completed', () => { + const store = createStore() + store.set(unviewedCompletedSessionIdsAtom, new Set(['session-error'])) + store.set(agentStreamErrorsAtom, new Map([ + ['session-error', '重试失败'], + ])) + + expect(store.get(agentSessionIndicatorMapAtom).get('session-error')).toBe('error') + }) + + test('given pending permission request when deriving indicators then blocked overrides error and running', () => { + const store = createStore() + store.set(agentStreamingStatesAtom, new Map([ + ['session-blocked', runningState()], + ])) + store.set(agentStreamErrorsAtom, new Map([ + ['session-blocked', '旧错误不应盖过待审批'], + ])) + store.set(allPendingPermissionRequestsAtom, new Map([ + ['session-blocked', [permissionRequest('session-blocked')]], + ])) + + expect(store.get(agentSessionIndicatorMapAtom).get('session-blocked')).toBe('blocked') + }) + + test('given pending AskUser request when deriving indicators then session is blocked', () => { + const store = createStore() + store.set(agentStreamingStatesAtom, new Map([ + ['session-ask', runningState()], + ])) + store.set(allPendingAskUserRequestsAtom, new Map([ + ['session-ask', [askUserRequest('session-ask')]], + ])) + + expect(store.get(agentSessionIndicatorMapAtom).get('session-ask')).toBe('blocked') + }) + + test('given pending ExitPlan request when deriving indicators then session is blocked', () => { + const store = createStore() + store.set(agentStreamingStatesAtom, new Map([ + ['session-plan', runningState()], + ])) + store.set(allPendingExitPlanRequestsAtom, new Map([ + ['session-plan', [exitPlanRequest('session-plan')]], + ])) + + expect(store.get(agentSessionIndicatorMapAtom).get('session-plan')).toBe('blocked') + }) +}) diff --git a/apps/electron/src/renderer/atoms/agent-atoms.ts b/apps/electron/src/renderer/atoms/agent-atoms.ts index 526028fab..7c7e3dc10 100644 --- a/apps/electron/src/renderer/atoms/agent-atoms.ts +++ b/apps/electron/src/renderer/atoms/agent-atoms.ts @@ -551,7 +551,7 @@ export const agentRunningSessionIdsAtom = atom>((get) => { }) /** 侧边栏会话指示点状态 */ -export type SessionIndicatorStatus = 'idle' | 'running' | 'blocked' | 'completed' +export type SessionIndicatorStatus = 'idle' | 'running' | 'blocked' | 'completed' | 'error' /** 已完成但用户尚未查看的会话 ID 集合 */ export const unviewedCompletedSessionIdsAtom = atom>(new Set()) @@ -580,7 +580,7 @@ export const dockBadgeCountAtom = atom((get) => { /** * 每个会话的指示点状态(只包含非 idle 的会话) - * 优先级:blocked > running > completed > idle + * 优先级:blocked > error > running > completed > idle */ export const agentSessionIndicatorMapAtom = atom>((get) => { const streamStates = get(agentStreamingStatesAtom) @@ -588,6 +588,7 @@ export const agentSessionIndicatorMapAtom = atom() @@ -599,6 +600,12 @@ export const agentSessionIndicatorMapAtom = atom session.parentSessionId === parentSessionId && session.sourceDelegationId) +} + +function aggregateHeaderStatus( + sessionId: string, + sessions: AgentSessionMeta[], + indicatorMap: Map, +): SessionIndicatorStatus { + const childSessions = getDirectDelegatedChildren(sessions, sessionId) + const statuses = [ + indicatorMap.get(sessionId) ?? 'idle', + ...childSessions.map((session) => { + const status = indicatorMap.get(session.id) + if (status) return status + return session.delegationStatus === 'running' ? 'running' : 'idle' + }), + ] + + if (statuses.includes('blocked')) return 'blocked' + if (statuses.includes('error')) return 'error' + if (statuses.includes('running')) return 'running' + if (statuses.includes('completed')) return 'completed' + return 'idle' +} + export function AgentHeader({ sessionId }: AgentHeaderProps): React.ReactElement | null { const isWindows = React.useMemo(() => detectIsWindows(), []) const sessions = useAtomValue(agentSessionsAtom) + const indicatorMap = useAtomValue(agentSessionIndicatorMapAtom) + const streamState = useAtomValue(agentSessionStreamingStateAtomFamily(sessionId)) + const streamErrors = useAtomValue(agentStreamErrorsAtom) const session = sessions.find((s) => s.id === sessionId) ?? null + const status = aggregateHeaderStatus(sessionId, sessions, indicatorMap) + const errorMessage = streamErrors.get(sessionId) ?? null + const delegationSummary = buildAgentDelegationProgressSummary( + getDirectDelegatedChildren(sessions, sessionId), + streamState?.running ? streamState.startedAt : undefined, + ) const setAgentSessions = useSetAtom(agentSessionsAtom) const setTabs = useSetAtom(tabsAtom) const [editing, setEditing] = React.useState(false) @@ -102,9 +139,15 @@ export function AgentHeader({ sessionId }: AgentHeaderProps): React.ReactElement ) : (
- + {session.title} +