Skip to content

Commit b6fb5b5

Browse files
authored
fix: show approvals raised after a running Session is reloaded (#2026)
1 parent 80bb458 commit b6fb5b5

4 files changed

Lines changed: 422 additions & 3 deletions

File tree

‎src/components/ChatBox/UserQueryGroup.tsx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
1414

1515
import { isUserMessageReplyToAsk } from '@/lib/humanInteractionMessages';
16-
import { useRunWriterWait } from '@/lib/runEvents';
16+
import { useProjectedRunStatus, useRunWriterWait } from '@/lib/runEvents';
1717
import { inferSessionModeFromTask } from '@/lib/sessionMode';
1818
import { resolveWorkspaceFilePath } from '@/lib/workspaceRelativePath';
1919
import { completeHumanInteraction } from '@/service/humanInteractionCompletion';
@@ -209,6 +209,10 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
209209
(state) => state.activeProjectId
210210
);
211211
const writerWait = useRunWriterWait(activeProjectId, activeTaskId);
212+
const projectedRunStatus = useProjectedRunStatus(
213+
activeProjectId,
214+
activeTaskId
215+
);
212216

213217
// Subscribe to streaming decompose text separately for efficient updates
214218
const streamingDecomposeText = useSyncExternalStore(
@@ -234,6 +238,16 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
234238
);
235239

236240
const activeTask = activeTaskId ? chatState.tasks[activeTaskId] : undefined;
241+
// A restored Run keeps the status read when its Session was loaded. A
242+
// request raised after that reaches the same replay stream but only moves
243+
// the Run projection, so it may lift a status that has not ended.
244+
const replayRunStatus =
245+
projectedRunStatus === 'waiting_for_user' &&
246+
(activeTask?.durableRunStatus === undefined ||
247+
activeTask.durableRunStatus === 'pending' ||
248+
activeTask.durableRunStatus === 'running')
249+
? projectedRunStatus
250+
: activeTask?.durableRunStatus;
237251
const ownsFailureSummary =
238252
queryGroup.ownsRunWorkLog === true &&
239253
UNSUCCESSFUL_RUN_STATUSES.has(activeTask?.durableRunStatus!) &&
@@ -523,7 +537,7 @@ export const UserQueryGroup: React.FC<UserQueryGroupProps> = ({
523537
activeTaskId,
524538
taskType: activeTask.type,
525539
taskStatus: activeTask.status,
526-
durableRunStatus: activeTask.durableRunStatus,
540+
durableRunStatus: replayRunStatus,
527541
});
528542

529543
// A replay reattached to a live durable waiter remains actionable even

‎src/lib/runEvents/react.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
1414
// Licensed under the Apache License, Version 2.0 (the "License");
1515

16-
import type { ProjectedWriterWait, ProjectViewState } from '@/lib/projector';
16+
import type {
17+
ProjectedRun,
18+
ProjectedWriterWait,
19+
ProjectViewState,
20+
} from '@/lib/projector';
1721
import { useCallback, useSyncExternalStore } from 'react';
1822
import { runProjectionStore } from './projectionStore';
1923

@@ -47,3 +51,16 @@ export function useRunWriterWait(
4751
);
4852
return useRunProjectionSelector(projectId, select);
4953
}
54+
55+
/** The canonical status of a Run, or null before it is projected. */
56+
export function useProjectedRunStatus(
57+
projectId: string | null,
58+
runId: string | null | undefined
59+
): ProjectedRun['status'] | null {
60+
const select = useCallback(
61+
(state: ProjectViewState | null) =>
62+
(runId && state?.runs[runId]?.status) || null,
63+
[runId]
64+
);
65+
return useRunProjectionSelector(projectId, select);
66+
}

‎test/unit/components/ChatBox/UserQueryGroup.test.tsx‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { VanillaChatStore } from '@/store/chatStore';
1818
import { useSpaceStore } from '@/store/spaceStore';
1919
import { AgentStep, ChatTaskStatus, SessionMode } from '@/types/constants';
2020
import {
21+
act,
2122
cleanup,
2223
fireEvent,
2324
render,
@@ -56,6 +57,12 @@ vi.mock('@/store/projectRuntimeStore', () => ({
5657
),
5758
}));
5859

60+
// Brain confirms that every approval in these tests is still pending.
61+
vi.mock('@/service/humanInteractionApi', async (importOriginal) => ({
62+
...(await importOriginal<typeof import('@/service/humanInteractionApi')>()),
63+
isHumanInteractionStillPending: vi.fn(async () => true),
64+
}));
65+
5966
vi.mock('@/lib/projectRuntimeHydration', () => ({
6067
ensureProjectRuntimeLoaded: navigation.ensureProjectRuntimeLoaded,
6168
}));
@@ -586,3 +593,108 @@ describe('UserQueryGroup Space writer wait', () => {
586593
expect(screen.queryByRole('button', { name: 'Open session' })).toBeNull();
587594
});
588595
});
596+
597+
describe('UserQueryGroup restored Run requests', () => {
598+
const expiresAt = Date.now() / 1000 + 86_400;
599+
const messages = [
600+
{ id: 'user-1', role: 'user', content: 'Write two files' },
601+
{
602+
id: 'ask-two',
603+
role: 'agent',
604+
step: AgentStep.ASK,
605+
content: 'The agent wants to write a file.',
606+
agent_name: 'single_agent',
607+
interaction: {
608+
interaction_id: 'approval:call-two',
609+
interaction_type: 'approval',
610+
approval_id: 'approval:call-two',
611+
run_id: 'run-1',
612+
version: 0,
613+
expires_at: expiresAt,
614+
question: 'The agent wants to write a file.',
615+
title: 'Allow write_to_file?',
616+
agent: 'single_agent',
617+
action_digest: 'digest-two',
618+
allowed_scopes: ['once'],
619+
target_resources: [],
620+
},
621+
},
622+
];
623+
let version = 0;
624+
const project = (...eventTypes: string[]) => {
625+
const ingress = new RunEventIngress('session-waiting', 'run-1');
626+
for (const eventType of eventTypes) {
627+
version += 1;
628+
ingress.ingest({
629+
event_id: `restored-${version}`,
630+
project_id: 'session-waiting',
631+
run_id: 'run-1',
632+
run_sequence: version,
633+
run_version: version,
634+
event_type: eventType,
635+
legacy_step: null,
636+
created_at: '2026-10-07T10:00:00Z',
637+
payload:
638+
eventType === 'approval.requested'
639+
? { approval_id: 'approval:call-two', expires_at: expiresAt }
640+
: {},
641+
});
642+
}
643+
};
644+
const restored = (durableRunStatus: string | undefined) =>
645+
renderGroups(messages, { type: 'replay', durableRunStatus });
646+
const card = () => screen.queryByText('Allow write_to_file?');
647+
648+
afterEach(() => {
649+
cleanup();
650+
runProjectionStore.clear();
651+
version = 0;
652+
});
653+
654+
it('offers a request raised after the Session was restored', async () => {
655+
project('run.attempt_created', 'run.attempt_started');
656+
restored('running');
657+
expect(card()).toBeNull();
658+
659+
act(() => project('approval.requested'));
660+
661+
expect(card()).not.toBeNull();
662+
await waitFor(() =>
663+
expect(screen.getByRole('button', { name: 'Approve once' })).toBeEnabled()
664+
);
665+
});
666+
667+
it('keeps the card of a Run that was already waiting when restored', () => {
668+
restored('waiting_for_user');
669+
670+
expect(card()).not.toBeNull();
671+
});
672+
673+
it('keeps the request read-only after the Run was interrupted', () => {
674+
project('run.attempt_created', 'run.attempt_started', 'run.interrupted');
675+
restored('running');
676+
677+
expect(card()).toBeNull();
678+
});
679+
680+
it('never reopens a Run that has ended', () => {
681+
project('run.attempt_created', 'approval.requested');
682+
restored('completed');
683+
684+
expect(card()).toBeNull();
685+
});
686+
687+
it('offers the request when only the projection knows the Run waits', () => {
688+
project('run.attempt_created', 'approval.requested');
689+
restored(undefined);
690+
691+
expect(card()).not.toBeNull();
692+
});
693+
694+
it('keeps a replay read-only until its Run waits', () => {
695+
project('run.attempt_created', 'run.attempt_started');
696+
restored(undefined);
697+
698+
expect(card()).toBeNull();
699+
});
700+
});

0 commit comments

Comments
 (0)