diff --git a/.github/scripts/test-classify-ci-changes.sh b/.github/scripts/test-classify-ci-changes.sh index 1d4a0cd5b2f..970ad8ef4ee 100755 --- a/.github/scripts/test-classify-ci-changes.sh +++ b/.github/scripts/test-classify-ci-changes.sh @@ -209,6 +209,15 @@ for workflow in test.yml lint.yml; do fi done +for workflow in lint.yml test.yml e2e-tests.yml wework-e2e.yml; do + workflow_path="$script_dir/../workflows/$workflow" + if ! grep -Fq 'git diff --name-only "$BASE_SHA...$HEAD_SHA"' "$workflow_path"; then + printf '%s must classify pull request changes from the merge base\n' \ + "$workflow" >&2 + exit 1 + fi +done + wework_workflow="$script_dir/../workflows/wework-e2e.yml" if [[ "$(grep -c "github.event.action != 'labeled'" "$wework_workflow")" -lt 2 ]]; then printf 'Wework non-memory E2E jobs must filter pull_request label events\n' >&2 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 224d73d88d8..cd4ff4ae75d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -41,7 +41,7 @@ jobs: if [[ "$FORCE_ALL" == "true" ]]; then .github/scripts/classify-ci-changes.sh --all else - git diff --name-only "$BASE_SHA" "$HEAD_SHA" | + git diff --name-only "$BASE_SHA...$HEAD_SHA" | .github/scripts/classify-ci-changes.sh fi diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9f3567f650c..799cd0d8d67 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -48,7 +48,7 @@ jobs: [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/main" ]]; then .github/scripts/classify-ci-changes.sh --all else - git diff --name-only "$BASE_SHA" "$HEAD_SHA" | + git diff --name-only "$BASE_SHA...$HEAD_SHA" | .github/scripts/classify-ci-changes.sh fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 76e51e39619..9896c210069 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,7 +56,7 @@ jobs: # Merge groups, main pushes, and ci:all force every module test. .github/scripts/classify-ci-changes.sh --all else - git diff --name-only "$BASE_SHA" "$HEAD_SHA" | + git diff --name-only "$BASE_SHA...$HEAD_SHA" | .github/scripts/classify-ci-changes.sh fi diff --git a/.github/workflows/wework-e2e.yml b/.github/workflows/wework-e2e.yml index bded8d9402e..c78d97bd04b 100644 --- a/.github/workflows/wework-e2e.yml +++ b/.github/workflows/wework-e2e.yml @@ -54,7 +54,7 @@ jobs: .github/scripts/classify-wework-desktop-e2e.sh --all else changed_files="$(mktemp)" - git diff --name-only "$BASE_SHA" "$HEAD_SHA" > "$changed_files" + git diff --name-only "$BASE_SHA...$HEAD_SHA" > "$changed_files" .github/scripts/classify-ci-changes.sh < "$changed_files" .github/scripts/classify-wework-desktop-e2e.sh < "$changed_files" fi diff --git a/docs/en/wework/developer-guide/wework-chat-state-sources.md b/docs/en/wework/developer-guide/wework-chat-state-sources.md index 6932ce30833..77918ece2b6 100644 --- a/docs/en/wework/developer-guide/wework-chat-state-sources.md +++ b/docs/en/wework/developer-guide/wework-chat-state-sources.md @@ -205,6 +205,14 @@ A reference is serialized in the draft as `[$title](wework-conversation:// + testId.startsWith('runtime-local-task-row-') + ) + ) + const concurrentRequestCount = control.scenarioRequests.get('concurrent_memory')?.length ?? 0 + control.setScenario('concurrent_memory') + await control.command('click', '[data-testid="new-chat-button"]') + await control.command('waitFor', composerSelector, { + timeoutMs: WORKBENCH_READY_TIMEOUT_MS, + }) + await sendPrompt(control, composerSelector, CONVERSATION_SWITCH_RACE_PROMPT) + await control.awaitScenarioRequestCount('concurrent_memory', concurrentRequestCount + 1) + const runningTaskRowTestId = await waitForNewTaskRow( + control, + taskRowsBeforeRace, + 'WEWORK_DESKTOP_E2E_CONCURRENT_MEMORY_1' + ) + + await ensureTaskRowVisible(control, shortConversationTaskRowTestId) + await control.command('clickWhenEnabled', `[data-testid="${shortConversationTaskRowTestId}"]`, { + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command('waitFor', '[data-testid="message-assistant"]', { + text: FRESH_CHAT_COMPLETION_TEXT, + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + + await ensureTaskRowVisible(control, runningTaskRowTestId) + await control.command('clickThenMacrotask', `[data-testid="${runningTaskRowTestId}"]`, { + target: `[data-testid="${shortConversationTaskRowTestId}"]`, + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + await control.command('waitFor', '[data-testid="message-assistant"]', { + text: FRESH_CHAT_COMPLETION_TEXT, + timeoutMs: DEFAULT_STEP_TIMEOUT_MS, + }) + + const restoredAfterRace = JSON.parse( + await control.command( + 'snapshot', + `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="desktop-chat-scroll-content"]` + ) + ) + assert.ok( + countTextOccurrences(restoredAfterRace.text, FRESH_CHAT_PROMPT) >= 2, + 'Rapid conversation switching lost an earlier user message' + ) + assert.ok( + countTextOccurrences(restoredAfterRace.text, FRESH_CHAT_COMPLETION_TEXT) >= 2, + 'Rapid conversation switching lost an earlier assistant message' + ) + await captureVerificationScreenshot(control, 'short-conversation-03-rapid-switch-restored.png') + control.releaseConcurrentMemoryResponses() + const runningTaskId = runningTaskRowTestId.replace('runtime-local-task-row-', '') + await waitForSnapshot( + control, + snapshot => !snapshot.testIds.includes(`runtime-local-task-running-${runningTaskId}`), + 'The rapid-switch background task did not settle after its response was released' + ) + const settledAfterRace = JSON.parse( + await control.command( + 'snapshot', + `${ACTIVE_WORKBENCH_SELECTOR} [data-testid="desktop-chat-scroll-content"]` + ) + ) + assert.ok( + countTextOccurrences(settledAfterRace.text, FRESH_CHAT_PROMPT) >= 2, + 'The settled rapid switch lost an earlier user message' + ) + assert.ok( + countTextOccurrences(settledAfterRace.text, FRESH_CHAT_COMPLETION_TEXT) >= 2, + 'The settled rapid switch lost an earlier assistant message' + ) + assert.equal( + settledAfterRace.text.includes(CONVERSATION_SWITCH_RACE_PROMPT), + false, + 'The late background transcript leaked into the restored conversation' + ) + control.setScenario('fresh_chat') + return shortConversationTaskRowTestId } function countTextOccurrences(value, search) { @@ -11159,11 +11243,6 @@ async function main() { } phase = 'fresh-chat' control.setScenario('fresh_chat') - const taskRowsBeforeFreshChat = new Set( - JSON.parse(await control.command('snapshot', 'body')).testIds.filter(testId => - testId.startsWith('runtime-local-task-row-') - ) - ) await control.command('click', '[data-testid="new-chat-button"]') await control.command('waitFor', composerSelector, { timeoutMs: WORKBENCH_READY_TIMEOUT_MS, @@ -11175,23 +11254,12 @@ async function main() { false, 'The new conversation retained the previous task' ) - await verifyShortConversationLayout({ composerSelector, control }) + const secondTaskRowTestId = await verifyShortConversationLayout({ + composerSelector, + control, + }) phase = 'task-draft-isolation' - const secondTaskSnapshot = await waitForSnapshot( - control, - snapshot => - snapshot.testIds.some( - testId => - testId.startsWith('runtime-local-task-row-') && !taskRowsBeforeFreshChat.has(testId) - ), - 'The second task was not available for task draft isolation' - ) - const secondTaskRowTestId = secondTaskSnapshot.testIds.find( - testId => - testId.startsWith('runtime-local-task-row-') && !taskRowsBeforeFreshChat.has(testId) - ) - assert.ok(secondTaskRowTestId, 'The second task row was not found') await control.command('fill', composerSelector, { value: UNSENT_SECOND_TASK_DRAFT }) await waitForPersistedComposerInput( control, diff --git a/wework/src/components/layout/useWorkbenchPaneSession.ts b/wework/src/components/layout/useWorkbenchPaneSession.ts index 8d177cde6da..5a9a700a4a9 100644 --- a/wework/src/components/layout/useWorkbenchPaneSession.ts +++ b/wework/src/components/layout/useWorkbenchPaneSession.ts @@ -538,7 +538,10 @@ export function useWorkbenchPaneSession({ currentRuntimeTask }: WorkbenchPaneSes } const { key: loadKey, address } = runtimeTaskLoadTarget - if (loadedRuntimeTranscriptKeyRef.current === loadKey) { + if ( + loadedRuntimeTranscriptKeyRef.current === loadKey && + displayedTranscriptIdentityRef.current === runtimeTaskLoadTarget.identityKey + ) { return } diff --git a/wework/src/e2e/automation.ts b/wework/src/e2e/automation.ts index d5e98de885b..86115652cc1 100644 --- a/wework/src/e2e/automation.ts +++ b/wework/src/e2e/automation.ts @@ -893,6 +893,26 @@ async function executeDesktopControlCommand(command: DesktopControlCommand): Pro element.click() return element.textContent?.trim() ?? '' } + case 'clickThenMacrotask': { + const element = findDesktopControlElements(command.selector)[0] + if (!element) throw new Error(`Unable to find selector "${command.selector}"`) + if (!desktopControlElementEnabled(element)) { + throw new Error(`Selector "${command.selector}" is disabled`) + } + const targetSelector = command.target?.trim() + if (!targetSelector) throw new Error('clickThenMacrotask requires target') + + element.click() + await new Promise(resolve => window.setTimeout(resolve, 0)) + + const target = findDesktopControlElements(targetSelector)[0] + if (!target) throw new Error(`Unable to find target selector "${targetSelector}"`) + if (!desktopControlElementEnabled(target)) { + throw new Error(`Target selector "${targetSelector}" is disabled`) + } + target.click() + return target.textContent?.trim() ?? '' + } case 'clickIfPresent': { const element = findDesktopControlElements(command.selector).find( desktopControlElementEnabled diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index 05c5e5b3b17..8953e24305e 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -7011,6 +7011,68 @@ describe('WorkbenchProvider runtime tasks', () => { expect(screen.getByTestId('runtime-open-messages')).not.toHaveTextContent('message a') }) + test('restores cached history when returning before another transcript finishes loading', async () => { + const runtimeBTranscript = deferred() + const getRuntimeTranscript = vi.fn((address: RuntimeTaskAddress) => { + if (address.taskId === 'runtime-b') return runtimeBTranscript.promise + return Promise.resolve({ + taskId: 'runtime-a', + workspacePath: '/workspace/project-alpha', + runtime: 'claude_code', + messages: [{ id: 'runtime-a:user:1', role: 'user', content: 'message a' }], + } satisfies RuntimeTranscriptResponse) + }) + const runtimeWorkApi = createRuntimeWorkApiMock({ getRuntimeTranscript }) + const services = createWorkbenchServices({ + runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], + }) + + renderWorkbench(, services) + + await userEvent.click(await screen.findByText('open runtime a')) + await waitFor(() => + expect(screen.getByTestId('runtime-open-messages')).toHaveTextContent('message a') + ) + + await userEvent.click(screen.getByText('open runtime b')) + await waitFor(() => + expect(screen.getByTestId('current-runtime-task-address')).toHaveTextContent( + 'device-1:runtime-b' + ) + ) + await waitFor(() => + expect(screen.getByTestId('runtime-transcript-loading')).toHaveTextContent('loading') + ) + expect(screen.getByTestId('runtime-open-messages')).toBeEmptyDOMElement() + + await userEvent.click(screen.getByText('open runtime a')) + await waitFor(() => + expect(screen.getByTestId('current-runtime-task-address')).toHaveTextContent( + 'device-1:runtime-a' + ) + ) + await waitFor(() => + expect(screen.getByTestId('runtime-open-messages')).toHaveTextContent('message a') + ) + expect(getRuntimeTranscript).toHaveBeenCalledTimes(3) + + await act(async () => { + runtimeBTranscript.resolve({ + taskId: 'runtime-b', + workspacePath: '/workspace/project-alpha', + runtime: 'claude_code', + messages: [{ id: 'runtime-b:user:1', role: 'user', content: 'message b' }], + }) + await runtimeBTranscript.promise + }) + + expect(screen.getByTestId('current-runtime-task-address')).toHaveTextContent( + 'device-1:runtime-a' + ) + expect(screen.getByTestId('runtime-open-messages')).toHaveTextContent('message a') + expect(screen.getByTestId('runtime-open-messages')).not.toHaveTextContent('message b') + }) + test('does not reload the currently selected runtime task when clicked again', async () => { const getRuntimeTranscript = vi.fn().mockResolvedValue({ taskId: 'runtime-a',