mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-21 22:53:33 +00:00
fix(chat): preserve canonical run identity in controls
This commit is contained in:
parent
8ffb19a67c
commit
b8b8fd184c
6 changed files with 346 additions and 15 deletions
|
|
@ -42,6 +42,7 @@ import {
|
|||
terminalContinuationAdmissionRejection,
|
||||
} from '@/service/followUpQueueApi';
|
||||
import { decideHumanInteraction } from '@/service/humanInteractionApi';
|
||||
import { cancelProjectRun } from '@/service/projectRunsApi';
|
||||
import { proxyUpdateTriggerExecution } from '@/service/triggerApi';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { isChatEventTimelineEnabled } from '@/store/chatEventProjectionBridge';
|
||||
|
|
@ -83,6 +84,7 @@ import { ProjectChatContainer } from './ProjectChatContainer';
|
|||
import {
|
||||
isEventNativeRunActionable,
|
||||
selectActionableInterruptedRun,
|
||||
selectComposerTaskControlState,
|
||||
selectEventNativeActiveRunId,
|
||||
} from './runControlArbitration';
|
||||
import { PLAN_OVERLAY_SLOT_ID } from './TaskBox/PlanTaskBox';
|
||||
|
|
@ -1879,6 +1881,40 @@ export default function ChatBox(): JSX.Element {
|
|||
void handleCancelInterruptedRun();
|
||||
};
|
||||
|
||||
const handleEventNativeStopRun = async (runId: string) => {
|
||||
const currentRunId = selectEventNativeActiveRunId(
|
||||
eventNativeProjectSnapshot,
|
||||
eligibleLegacyActiveRunId
|
||||
);
|
||||
const currentRun = currentRunId
|
||||
? eventNativeProjectSnapshot?.view.runs[currentRunId]
|
||||
: undefined;
|
||||
if (
|
||||
runId !== currentRunId ||
|
||||
currentRun?.status !== 'running' ||
|
||||
isPauseResumeLoading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPauseResumeLoading(true);
|
||||
try {
|
||||
await cancelProjectRun(
|
||||
runId,
|
||||
runActionRequestId('cancel', runId),
|
||||
'explicit_stop_from_event_native_chatbox'
|
||||
);
|
||||
clearRunActionRequestId('cancel', runId);
|
||||
if (chatStore.tasks[runId]) chatStore.setIsPending(runId, false);
|
||||
toast.success('Run stopped successfully', { closeButton: true });
|
||||
} catch (error: any) {
|
||||
console.error('[RunControl] Failed to stop Run', error);
|
||||
toast.error(error?.message || 'Failed to stop this Run.');
|
||||
} finally {
|
||||
setIsPauseResumeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
let eventNativeRunControlVariant: BottomBoxRunControlVariant | null = null;
|
||||
if (
|
||||
eventNativeTimelineEnabled &&
|
||||
|
|
@ -1966,13 +2002,12 @@ export default function ChatBox(): JSX.Element {
|
|||
});
|
||||
const bottomBoxVariant = bottomBoxControl.variant;
|
||||
const hasControlledBottomBoxVariant = bottomBoxControl.isControlled;
|
||||
const composerTaskControlState =
|
||||
activeTask?.status === ChatTaskStatus.PAUSE
|
||||
? 'paused'
|
||||
: activeTask?.status === ChatTaskStatus.RUNNING ||
|
||||
eventNativeActiveProjectedRun?.status === 'running'
|
||||
? 'running'
|
||||
: 'idle';
|
||||
const composerTaskControlState = selectComposerTaskControlState({
|
||||
eventNativeTimelineEnabled,
|
||||
legacyControlRunId: eligibleLegacyActiveRunId,
|
||||
activeTaskStatus: activeTask?.status,
|
||||
eventNativeActiveRunId,
|
||||
});
|
||||
const chatColumn = (
|
||||
<>
|
||||
{/* Main: scroll (scrollbar on panel edge) + BottomBox overlay when chatting */}
|
||||
|
|
@ -1991,7 +2026,11 @@ export default function ChatBox(): JSX.Element {
|
|||
eventNativeActiveProjectedRun?.status === 'running' ? (
|
||||
<FloatingAction
|
||||
status={ChatTaskStatus.RUNNING}
|
||||
onSkip={handleSkip}
|
||||
onSkip={() =>
|
||||
void handleEventNativeStopRun(
|
||||
eventNativeActiveProjectedRun.runId
|
||||
)
|
||||
}
|
||||
loading={isPauseResumeLoading}
|
||||
/>
|
||||
) : null
|
||||
|
|
|
|||
|
|
@ -13,10 +13,40 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore';
|
||||
import { ChatTaskStatus, type ChatTaskStatusType } from '@/types/constants';
|
||||
|
||||
export type EventNativeProjectedRun =
|
||||
ProjectEventStoreSnapshot['view']['runs'][string];
|
||||
|
||||
export type ComposerTaskControlState = 'idle' | 'running' | 'paused';
|
||||
|
||||
/**
|
||||
* Pause/resume still targets the Project-scoped legacy TaskLock. It is safe to
|
||||
* expose only when the compatibility task owns the same Run selected by the
|
||||
* event-native control arbitration.
|
||||
*/
|
||||
export function selectComposerTaskControlState({
|
||||
eventNativeTimelineEnabled,
|
||||
legacyControlRunId,
|
||||
activeTaskStatus,
|
||||
eventNativeActiveRunId,
|
||||
}: {
|
||||
eventNativeTimelineEnabled: boolean;
|
||||
legacyControlRunId: string | null | undefined;
|
||||
activeTaskStatus: ChatTaskStatusType | null | undefined;
|
||||
eventNativeActiveRunId: string | null | undefined;
|
||||
}): ComposerTaskControlState {
|
||||
if (
|
||||
eventNativeTimelineEnabled &&
|
||||
(!legacyControlRunId || eventNativeActiveRunId !== legacyControlRunId)
|
||||
) {
|
||||
return 'idle';
|
||||
}
|
||||
if (activeTaskStatus === ChatTaskStatus.PAUSE) return 'paused';
|
||||
if (activeTaskStatus === ChatTaskStatus.RUNNING) return 'running';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
const PENDING_CONTROL_RUN_STATUSES = new Set([
|
||||
'pending',
|
||||
'running',
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { fetchGet } from '@/api/http';
|
||||
import { fetchGet, fetchPost } from '@/api/http';
|
||||
|
||||
export type ProjectRunsResponse = {
|
||||
project_id?: unknown;
|
||||
|
|
@ -28,6 +28,11 @@ export type ProjectRunsResponse = {
|
|||
cloud_restore_pending?: unknown;
|
||||
};
|
||||
|
||||
type RunControlRequest = (
|
||||
url: string,
|
||||
data: { request_id: string; reason: string }
|
||||
) => Promise<unknown>;
|
||||
|
||||
const inFlightProjectRuns = new Map<string, Promise<ProjectRunsResponse>>();
|
||||
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
|
|
@ -98,3 +103,16 @@ export function fetchProjectRuns(
|
|||
}
|
||||
return waitForCaller(request, signal);
|
||||
}
|
||||
|
||||
/** Cancel one exact durable Run; callers own stable request-id generation. */
|
||||
export function cancelProjectRun(
|
||||
runId: string,
|
||||
requestId: string,
|
||||
reason: string,
|
||||
request: RunControlRequest = fetchPost
|
||||
): Promise<unknown> {
|
||||
return request(`/runs/${encodeURIComponent(runId)}/cancel`, {
|
||||
request_id: requestId,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ import {
|
|||
import ChatBox from '../../../src/components/ChatBox/index';
|
||||
import { useAuthStore } from '../../../src/store/authStore';
|
||||
|
||||
const eventNativeHarness = vi.hoisted(() => ({
|
||||
enabled: false,
|
||||
snapshot: null as any,
|
||||
}));
|
||||
|
||||
// Mock dependencies (use the same relative paths as the imports above)
|
||||
vi.mock('../../../src/store/authStore', () => ({
|
||||
useAuthStore: vi.fn(),
|
||||
|
|
@ -54,6 +59,43 @@ vi.mock('@/api/http', () => ({
|
|||
proxyFetchGet: vi.fn(),
|
||||
proxyFetchDelete: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/store/chatEventProjectionBridge', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@/store/chatEventProjectionBridge')>();
|
||||
return {
|
||||
...actual,
|
||||
isChatEventTimelineEnabled: () => eventNativeHarness.enabled,
|
||||
};
|
||||
});
|
||||
vi.mock('@/hooks/useProjectEventRuntime', () => ({
|
||||
useProjectEventRuntime: () => ({
|
||||
hydration: {
|
||||
status: 'ready',
|
||||
errorCode: null,
|
||||
eventsTruncated: false,
|
||||
retry: vi.fn(),
|
||||
},
|
||||
projectId: 'test-project-id',
|
||||
snapshot: eventNativeHarness.snapshot,
|
||||
}),
|
||||
}));
|
||||
vi.mock('../../../src/components/ChatBox/EventNativeProjectTimeline', () => ({
|
||||
EventNativeProjectTimeline: ({ floatingControl }: any) => (
|
||||
<div data-testid="event-native-timeline">{floatingControl}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock(
|
||||
'../../../src/components/ChatBox/BottomBox/useEventNativeHumanControl',
|
||||
() => ({
|
||||
useEventNativeHumanControl: () => ({
|
||||
interaction: null,
|
||||
variant: null,
|
||||
pendingCount: 0,
|
||||
phase: 'idle',
|
||||
submitError: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
vi.mock('../../../src/lib', () => ({
|
||||
generateUniqueId: vi.fn(() => 'test-unique-id'),
|
||||
replayActiveTask: vi.fn(),
|
||||
|
|
@ -243,8 +285,8 @@ describe('ChatBox Component', async () => {
|
|||
setTaskSessionMode: vi.fn(),
|
||||
setTaskSource: vi.fn(),
|
||||
setExecutionId: vi.fn(),
|
||||
removeMessage: vi.fn(),
|
||||
removeTask: vi.fn(),
|
||||
stopTask: vi.fn(),
|
||||
setElapsed: vi.fn(),
|
||||
setTaskTime: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
|
|
@ -301,9 +343,70 @@ describe('ChatBox Component', async () => {
|
|||
modelType: 'cloud',
|
||||
};
|
||||
|
||||
const runningEventNativeSnapshot = (runId = 'test-task-id') => ({
|
||||
view: {
|
||||
projectId: 'test-project-id',
|
||||
mode: 'live',
|
||||
seenEventIds: {},
|
||||
currentCursor: 1,
|
||||
eventsTruncated: false,
|
||||
lastSyncedAt: null,
|
||||
needsResync: false,
|
||||
resyncReason: null,
|
||||
resyncTargetCursor: null,
|
||||
runs: {
|
||||
[runId]: {
|
||||
runId,
|
||||
status: 'running',
|
||||
lastSequence: 1,
|
||||
runVersion: 1,
|
||||
updatedAt: '2026-08-20T00:00:00Z',
|
||||
origin: 'local',
|
||||
resumeBlockedReason: null,
|
||||
},
|
||||
},
|
||||
artifactsByRun: {},
|
||||
legacySteps: [],
|
||||
unknownEvents: [],
|
||||
},
|
||||
chat: {
|
||||
projectId: 'test-project-id',
|
||||
nodes: [
|
||||
{
|
||||
id: `${runId}:started`,
|
||||
eventId: `${runId}:started`,
|
||||
projectId: 'test-project-id',
|
||||
runId,
|
||||
createdAt: '2026-08-20T00:00:00Z',
|
||||
runSequence: 1,
|
||||
cloudCursor: 1,
|
||||
eventType: 'run.attempt_started',
|
||||
legacyStep: null,
|
||||
kind: 'run_status',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
nodeById: {},
|
||||
seenEventIds: {},
|
||||
},
|
||||
control: {
|
||||
projectId: 'test-project-id',
|
||||
orderedInteractionIds: [],
|
||||
interactionById: {},
|
||||
seenEventIds: {},
|
||||
},
|
||||
revision: 1,
|
||||
hasHydratedSnapshot: true,
|
||||
overflowed: false,
|
||||
lastEffects: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
window.sessionStorage.clear();
|
||||
eventNativeHarness.enabled = false;
|
||||
eventNativeHarness.snapshot = null;
|
||||
|
||||
// Setup default store states
|
||||
mockUseChatStoreAdapter.mockReturnValue({
|
||||
|
|
@ -671,6 +774,88 @@ describe('ChatBox Component', async () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Event-native floating Stop', () => {
|
||||
const setRunningEventNativeStore = () => {
|
||||
const runningTask = {
|
||||
...defaultChatStoreState.tasks['test-task-id'],
|
||||
status: 'running',
|
||||
hasMessages: true,
|
||||
messages: [{ id: '1', role: 'user', content: 'Start', attaches: [] }],
|
||||
};
|
||||
const runningStore = {
|
||||
...defaultChatStoreState,
|
||||
tasks: { 'test-task-id': runningTask },
|
||||
stopTask: vi.fn(),
|
||||
setIsPending: vi.fn(),
|
||||
};
|
||||
mockUseChatStoreAdapter.mockReturnValue({
|
||||
projectStore: defaultProjectStoreState as any,
|
||||
chatStore: runningStore as any,
|
||||
});
|
||||
eventNativeHarness.enabled = true;
|
||||
eventNativeHarness.snapshot = runningEventNativeSnapshot();
|
||||
return runningStore;
|
||||
};
|
||||
|
||||
it('fails closed when the rendered Run loses control ownership before click', async () => {
|
||||
const user = userEvent.setup();
|
||||
setRunningEventNativeStore();
|
||||
renderChatBox();
|
||||
const stopButton = await screen.findByRole('button', {
|
||||
name: 'Stop Task',
|
||||
});
|
||||
|
||||
const snapshot = eventNativeHarness.snapshot;
|
||||
snapshot.view.runs['typed-run'] = {
|
||||
...snapshot.view.runs['test-task-id'],
|
||||
runId: 'typed-run',
|
||||
};
|
||||
snapshot.control.orderedInteractionIds.push('typed-request');
|
||||
snapshot.control.interactionById['typed-request'] = {
|
||||
interactionId: 'typed-request',
|
||||
runId: 'typed-run',
|
||||
status: 'requested',
|
||||
requestSource: 'canonical',
|
||||
requestEventType: 'interaction.requested',
|
||||
};
|
||||
|
||||
await user.click(stopButton);
|
||||
|
||||
expect(_mockFetchPost).not.toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\/runs\//),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the request id after failure and never closes the legacy SSE', async () => {
|
||||
const user = userEvent.setup();
|
||||
const runningStore = setRunningEventNativeStore();
|
||||
const consoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
_mockFetchPost.mockRejectedValue(new Error('offline'));
|
||||
renderChatBox();
|
||||
const stopButton = await screen.findByRole('button', {
|
||||
name: 'Stop Task',
|
||||
});
|
||||
|
||||
await user.click(stopButton);
|
||||
await waitFor(() => expect(_mockFetchPost).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(stopButton).not.toBeDisabled());
|
||||
await user.click(stopButton);
|
||||
await waitFor(() => expect(_mockFetchPost).toHaveBeenCalledTimes(2));
|
||||
|
||||
const [firstUrl, firstBody] = _mockFetchPost.mock.calls[0];
|
||||
const [secondUrl, secondBody] = _mockFetchPost.mock.calls[1];
|
||||
expect(firstUrl).toBe('/runs/test-task-id/cancel');
|
||||
expect(secondUrl).toBe(firstUrl);
|
||||
expect(secondBody.request_id).toBe(firstBody.request_id);
|
||||
expect(runningStore.stopTask).not.toHaveBeenCalled();
|
||||
expect(runningStore.setIsPending).not.toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Management', () => {
|
||||
it('should render project chat container when tasks have messages', () => {
|
||||
mockUseChatStoreAdapter.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
import {
|
||||
selectActionableInterruptedRun,
|
||||
selectComposerTaskControlState,
|
||||
selectEventNativeActiveRunId,
|
||||
} from '@/components/ChatBox/runControlArbitration';
|
||||
import { createProjectViewState, type ProjectedRun } from '@/lib/projector';
|
||||
|
|
@ -26,6 +27,7 @@ import {
|
|||
type HumanControlInteraction,
|
||||
} from '@/lib/projector/control';
|
||||
import type { ProjectEventStoreSnapshot } from '@/store/projectEventStore';
|
||||
import { ChatTaskStatus } from '@/types/constants';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function run(
|
||||
|
|
@ -121,6 +123,41 @@ function snapshot({
|
|||
}
|
||||
|
||||
describe('event-native Run-control arbitration', () => {
|
||||
it('exposes Project-scoped pause only when the legacy task owns the selected Run', () => {
|
||||
expect(
|
||||
selectComposerTaskControlState({
|
||||
eventNativeTimelineEnabled: true,
|
||||
legacyControlRunId: 'legacy-live',
|
||||
activeTaskStatus: ChatTaskStatus.RUNNING,
|
||||
eventNativeActiveRunId: 'typed-input',
|
||||
})
|
||||
).toBe('idle');
|
||||
expect(
|
||||
selectComposerTaskControlState({
|
||||
eventNativeTimelineEnabled: true,
|
||||
legacyControlRunId: 'legacy-live',
|
||||
activeTaskStatus: ChatTaskStatus.RUNNING,
|
||||
eventNativeActiveRunId: 'legacy-live',
|
||||
})
|
||||
).toBe('running');
|
||||
expect(
|
||||
selectComposerTaskControlState({
|
||||
eventNativeTimelineEnabled: true,
|
||||
legacyControlRunId: 'legacy-live',
|
||||
activeTaskStatus: ChatTaskStatus.PAUSE,
|
||||
eventNativeActiveRunId: 'legacy-live',
|
||||
})
|
||||
).toBe('paused');
|
||||
expect(
|
||||
selectComposerTaskControlState({
|
||||
eventNativeTimelineEnabled: true,
|
||||
legacyControlRunId: null,
|
||||
activeTaskStatus: ChatTaskStatus.RUNNING,
|
||||
eventNativeActiveRunId: 'legacy-live',
|
||||
})
|
||||
).toBe('idle');
|
||||
});
|
||||
|
||||
it('lets a typed pending control outrank the legacy-owned live Run', () => {
|
||||
const state = snapshot({
|
||||
runs: [run('legacy-live', 'running'), run('input', 'waiting_for_user')],
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@
|
|||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { fetchGet } from '@/api/http';
|
||||
import { fetchProjectRuns } from '@/service/projectRunsApi';
|
||||
import { fetchGet, fetchPost } from '@/api/http';
|
||||
import { cancelProjectRun, fetchProjectRuns } from '@/service/projectRunsApi';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/api/http', () => ({ fetchGet: vi.fn() }));
|
||||
vi.mock('@/api/http', () => ({ fetchGet: vi.fn(), fetchPost: vi.fn() }));
|
||||
|
||||
const fetchGetMock = vi.mocked(fetchGet);
|
||||
const fetchPostMock = vi.mocked(fetchPost);
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
|
|
@ -28,8 +29,11 @@ function deferred<T>() {
|
|||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe('fetchProjectRuns', () => {
|
||||
beforeEach(() => fetchGetMock.mockReset());
|
||||
describe('project Runs API', () => {
|
||||
beforeEach(() => {
|
||||
fetchGetMock.mockReset();
|
||||
fetchPostMock.mockReset();
|
||||
});
|
||||
|
||||
it('shares concurrent reads and clears the request after it settles', async () => {
|
||||
const firstResponse = deferred<{ project_id: string; runs: never[] }>();
|
||||
|
|
@ -69,4 +73,22 @@ describe('fetchProjectRuns', () => {
|
|||
await expect(remaining).resolves.toBe(payload);
|
||||
expect(fetchGetMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels the exact encoded Run with the caller-owned request id', async () => {
|
||||
fetchPostMock.mockResolvedValue(undefined);
|
||||
|
||||
await cancelProjectRun(
|
||||
'run/with scope',
|
||||
'cancel:run-1:stable',
|
||||
'explicit_stop_from_event_native_chatbox'
|
||||
);
|
||||
|
||||
expect(fetchPostMock).toHaveBeenCalledWith(
|
||||
'/runs/run%2Fwith%20scope/cancel',
|
||||
{
|
||||
request_id: 'cancel:run-1:stable',
|
||||
reason: 'explicit_stop_from_event_native_chatbox',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue