fix(chat): preserve canonical run identity in controls

This commit is contained in:
4pmtong 2026-08-20 17:02:53 +08:00
parent 8ffb19a67c
commit b8b8fd184c
6 changed files with 346 additions and 15 deletions

View file

@ -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

View file

@ -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',

View file

@ -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,
});
}

View file

@ -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({

View file

@ -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')],

View file

@ -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',
}
);
});
});