diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index 8eddf7be..adaebd0d 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -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' ? ( + void handleEventNativeStopRun( + eventNativeActiveProjectedRun.runId + ) + } loading={isPauseResumeLoading} /> ) : null diff --git a/src/components/ChatBox/runControlArbitration.ts b/src/components/ChatBox/runControlArbitration.ts index 6fa612a1..30cb329a 100644 --- a/src/components/ChatBox/runControlArbitration.ts +++ b/src/components/ChatBox/runControlArbitration.ts @@ -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', diff --git a/src/service/projectRunsApi.ts b/src/service/projectRunsApi.ts index b497343f..55251acc 100644 --- a/src/service/projectRunsApi.ts +++ b/src/service/projectRunsApi.ts @@ -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; + const inFlightProjectRuns = new Map>(); 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 { + return request(`/runs/${encodeURIComponent(runId)}/cancel`, { + request_id: requestId, + reason, + }); +} diff --git a/test/unit/components/ChatBox.test.tsx b/test/unit/components/ChatBox.test.tsx index aedf7f3b..1b8520a2 100644 --- a/test/unit/components/ChatBox.test.tsx +++ b/test/unit/components/ChatBox.test.tsx @@ -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(); + 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) => ( +
{floatingControl}
+ ), +})); +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({ diff --git a/test/unit/components/ChatBox/runControlArbitration.test.ts b/test/unit/components/ChatBox/runControlArbitration.test.ts index 5da9759b..ef99fda5 100644 --- a/test/unit/components/ChatBox/runControlArbitration.test.ts +++ b/test/unit/components/ChatBox/runControlArbitration.test.ts @@ -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')], diff --git a/test/unit/service/projectRunsApi.test.ts b/test/unit/service/projectRunsApi.test.ts index 0e90ff74..f17d4f7b 100644 --- a/test/unit/service/projectRunsApi.test.ts +++ b/test/unit/service/projectRunsApi.test.ts @@ -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() { let resolve!: (value: T) => void; @@ -28,8 +29,11 @@ function deferred() { 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', + } + ); + }); });