Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/scripts/classify-wework-desktop-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ core_segments=(
core-task-flow
window-lifecycle
goal-lifecycle
supervisor-lifecycle
resilience
conversation-state
workspace-attachments
Expand Down Expand Up @@ -108,8 +109,12 @@ classify_wework_path() {

# Queueing, cancellation, retry, rate-limit, and reconnect behavior.
wework/src/components/chat/ConversationQueuePanel* | \
wework/src/lib/chat-error* | \
wework/src/features/workbench/runtimeTaskLifecycle/*)
wework/src/lib/chat-error*)
select_target "core:resilience"
return
;;
wework/src/features/workbench/runtimeTaskLifecycle/*)
select_target "core:supervisor-lifecycle"
select_target "core:resilience"
return
;;
Expand All @@ -126,6 +131,9 @@ classify_wework_path() {
wework/src/components/chat/MessageTurnNavigation* | \
wework/src/components/chat/ScrollableMessageArea*)
select_target "core:conversation-state"
if [[ "$path" == wework/src/features/workbench/runtimePaneMessages* ]]; then
select_target "core:supervisor-lifecycle"
fi
return
;;
wework/src/components/chat/MessageList*)
Expand Down
18 changes: 17 additions & 1 deletion .github/scripts/test-classify-ci-changes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,25 @@ wework_desktop_other_e2e_matrix={"include":[]}' \
"wework/e2e/desktop/task-flow.e2e.mjs" \
"wework/src/components/chat/MessageList.tsx"

assert_desktop_case "turn lifecycle changes select supervisor and resilience coverage" \
'wework_desktop_e2e=true
wework_desktop_core_e2e=true
wework_desktop_core_e2e_matrix={"include":[{"id":"core-supervisor-lifecycle","name":"Core / supervisor-lifecycle","command":"e2e:desktop","segment":"supervisor-lifecycle"},{"id":"core-resilience","name":"Core / resilience","command":"e2e:desktop","segment":"resilience"}]}
wework_desktop_other_e2e=false
wework_desktop_other_e2e_matrix={"include":[]}' \
"wework/src/features/workbench/runtimeTaskLifecycle/reducer.ts"

assert_desktop_case "runtime pane events select supervisor and conversation coverage" \
'wework_desktop_e2e=true
wework_desktop_core_e2e=true
wework_desktop_core_e2e_matrix={"include":[{"id":"core-supervisor-lifecycle","name":"Core / supervisor-lifecycle","command":"e2e:desktop","segment":"supervisor-lifecycle"},{"id":"core-conversation-state","name":"Core / conversation-state","command":"e2e:desktop","segment":"conversation-state"}]}
wework_desktop_other_e2e=false
wework_desktop_other_e2e_matrix={"include":[]}' \
"wework/src/features/workbench/runtimePaneMessages.ts"

full_desktop_expected='wework_desktop_e2e=true
wework_desktop_core_e2e=true
wework_desktop_core_e2e_matrix={"include":[{"id":"core-workspace-tabs","name":"Core / workspace-tabs","command":"e2e:desktop","segment":"workspace-tabs"},{"id":"core-priority-filter","name":"Core / priority-filter","command":"e2e:desktop","segment":"priority-filter"},{"id":"core-core-task-flow","name":"Core / core-task-flow","command":"e2e:desktop","segment":"core-task-flow"},{"id":"core-window-lifecycle","name":"Core / window-lifecycle","command":"e2e:desktop","segment":"window-lifecycle"},{"id":"core-goal-lifecycle","name":"Core / goal-lifecycle","command":"e2e:desktop","segment":"goal-lifecycle"},{"id":"core-resilience","name":"Core / resilience","command":"e2e:desktop","segment":"resilience"},{"id":"core-conversation-state","name":"Core / conversation-state","command":"e2e:desktop","segment":"conversation-state"},{"id":"core-workspace-attachments","name":"Core / workspace-attachments","command":"e2e:desktop","segment":"workspace-attachments"},{"id":"core-rendering-extensions","name":"Core / rendering-extensions","command":"e2e:desktop","segment":"rendering-extensions"}]}
wework_desktop_core_e2e_matrix={"include":[{"id":"core-workspace-tabs","name":"Core / workspace-tabs","command":"e2e:desktop","segment":"workspace-tabs"},{"id":"core-priority-filter","name":"Core / priority-filter","command":"e2e:desktop","segment":"priority-filter"},{"id":"core-core-task-flow","name":"Core / core-task-flow","command":"e2e:desktop","segment":"core-task-flow"},{"id":"core-window-lifecycle","name":"Core / window-lifecycle","command":"e2e:desktop","segment":"window-lifecycle"},{"id":"core-goal-lifecycle","name":"Core / goal-lifecycle","command":"e2e:desktop","segment":"goal-lifecycle"},{"id":"core-supervisor-lifecycle","name":"Core / supervisor-lifecycle","command":"e2e:desktop","segment":"supervisor-lifecycle"},{"id":"core-resilience","name":"Core / resilience","command":"e2e:desktop","segment":"resilience"},{"id":"core-conversation-state","name":"Core / conversation-state","command":"e2e:desktop","segment":"conversation-state"},{"id":"core-workspace-attachments","name":"Core / workspace-attachments","command":"e2e:desktop","segment":"workspace-attachments"},{"id":"core-rendering-extensions","name":"Core / rendering-extensions","command":"e2e:desktop","segment":"rendering-extensions"}]}
wework_desktop_other_e2e=true
wework_desktop_other_e2e_matrix={"include":[{"id":"plugins","name":"Plugins","command":"e2e:desktop:plugins","segment":""},{"id":"cloud","name":"Cloud","command":"e2e:desktop:cloud","segment":""}]}'

Expand Down
2 changes: 2 additions & 0 deletions wework/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,8 @@ semantics for all three.
- Runtime conversation events are keyed by task address and must update the
shared conversation cache even when that task's pane is not active or
mounted.
- Turn lifecycle events are additionally keyed by turn identity. A settlement
from an older turn must not clear the running state of a newer active turn.
- A guidance item remains pending until a matching runtime
`guidance_applied` event settles it. Match the client identifier first and
use guidance content only when the runtime replaces that identifier.
Expand Down
99 changes: 86 additions & 13 deletions wework/e2e/desktop/task-flow.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2913,14 +2913,10 @@ async function verifyExpandedToolDetail(
visible: true,
timeoutMs: DEFAULT_STEP_TIMEOUT_MS,
})
await control.command(
'waitFor',
`${selector} [data-tool-detail-toggle][aria-expanded="true"]`,
{
stableMs: 250,
timeoutMs: DEFAULT_STEP_TIMEOUT_MS,
}
)
await control.command('waitFor', `${selector} [data-tool-detail-toggle][aria-expanded="true"]`, {
stableMs: 250,
timeoutMs: DEFAULT_STEP_TIMEOUT_MS,
})
await captureVerificationScreenshot(control, screenshotName, selector)
await control.command('click', `${selector} [data-tool-detail-toggle]`)
}
Expand Down Expand Up @@ -6638,6 +6634,11 @@ async function verifyActiveGoalIdleUnreadLifecycle({ composerSelector, control,
async function verifyTaskSupervisorLifecycle({ composerSelector, control }) {
await ensureExperimentalFeaturesEnabled(control)
control.setScenario('supervisor')
const taskRowsBeforeSupervisor = 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,
Expand Down Expand Up @@ -6669,6 +6670,54 @@ async function verifyTaskSupervisorLifecycle({ composerSelector, control }) {
DEFAULT_STEP_TIMEOUT_MS,
'Auto-correction did not send a normal follow-up turn after the task became idle'
)
await withTimeout(
control.awaitSupervisorCorrectionResponseStarted(),
DEFAULT_STEP_TIMEOUT_MS,
'The supervisor correction response did not start'
)
const supervisorTaskRowTestId = await waitForNewTaskRow(
control,
taskRowsBeforeSupervisor,
'WEWORK_DESKTOP_E2E_SUPERVISOR'
)
const supervisorTaskId = supervisorTaskRowTestId.replace('runtime-local-task-row-', '')
const supervisorRunningTestId = `runtime-local-task-running-${supervisorTaskId}`
await control.command('waitFor', `[data-testid="${supervisorRunningTestId}"]`, {
timeoutMs: DEFAULT_STEP_TIMEOUT_MS,
})
const beforeStaleSettlement = JSON.parse(
await control.command('getWorkbenchDebugSnapshot', 'body')
)
assert.equal(
beforeStaleSettlement.workbench?.currentRuntimeTask?.taskId,
supervisorTaskId,
'The supervisor correction task was not current before replaying the stale settlement'
)
await control.command('dispatchRuntimeLifecycleEvent', 'body', {
value: JSON.stringify({
address: beforeStaleSettlement.workbench.currentRuntimeTask,
type: 'turn_settled',
turnId: 'stale-supervisor-turn',
}),
})
try {
const runningSnapshot = await waitForWorkbenchDebugState(
control,
snapshot =>
snapshot.workbench?.currentRuntimeTask?.taskId === supervisorTaskId &&
snapshot.workbench?.lifecycleCurrentTaskRunning === true &&
snapshot.pane?.status?.isBusy === true,
'The live supervisor correction was not represented as running'
)
assert.equal(
runningSnapshot.pane?.status?.taskExecution?.running,
true,
'The supervisor correction had a live executor response but task execution was idle'
)
await captureVerificationScreenshot(control, 'supervisor-01-correction-running.png')
} finally {
control.releaseSupervisorCorrectionResponse()
}
await control.command('waitFor', '[data-testid="message-user"]', {
text: SUPERVISOR_CORRECTION,
timeoutMs: DEFAULT_STEP_TIMEOUT_MS,
Expand Down Expand Up @@ -7266,6 +7315,12 @@ class DesktopE2EServer {
this.supervisorInitialRelease = new Promise(resolvePromise => {
this.releaseSupervisorInitial = resolvePromise
})
this.supervisorCorrectionStarted = new Promise(resolvePromise => {
this.resolveSupervisorCorrectionStarted = resolvePromise
})
this.supervisorCorrectionRelease = new Promise(resolvePromise => {
this.releaseSupervisorCorrection = resolvePromise
})
this.toolBlockNodeOutputObserved = new Promise(resolvePromise => {
this.resolveToolBlockNodeOutputObserved = resolvePromise
})
Expand Down Expand Up @@ -7601,6 +7656,14 @@ class DesktopE2EServer {
this.releaseSupervisorInitial()
}

awaitSupervisorCorrectionResponseStarted() {
return this.guard(this.supervisorCorrectionStarted)
}

releaseSupervisorCorrectionResponse() {
this.releaseSupervisorCorrection()
}

releaseGoalIdleInitialResponse() {
this.releaseGoalIdleInitial()
}
Expand Down Expand Up @@ -9066,11 +9129,21 @@ class DesktopE2EServer {
return
}
if (requestText.includes(SUPERVISOR_CORRECTION)) {
this.writeSse(response, [
responseCreated(responseId),
assistantMessage(SUPERVISOR_CORRECTION_COMPLETION_TEXT),
responseCompleted(responseId),
])
response.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Content-Type': 'text/event-stream; charset=utf-8',
})
response.write(
createSse([
responseCreated(responseId),
assistantMessage(SUPERVISOR_CORRECTION_COMPLETION_TEXT),
])
)
this.resolveSupervisorCorrectionStarted()
await this.supervisorCorrectionRelease
response.end(createSse([responseCompleted(responseId)]))
return
}
assert.ok(requestText.includes(SUPERVISOR_PROMPT), 'The supervisor task prompt was lost')
Expand Down
7 changes: 7 additions & 0 deletions wework/src/e2e/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,13 @@ async function executeDesktopControlCommand(command: DesktopControlCommand): Pro
case 'dispatchLocalModelSettingsChanged':
window.dispatchEvent(new CustomEvent(LOCAL_MODEL_SETTINGS_CHANGED_EVENT))
return ''
case 'dispatchRuntimeLifecycleEvent':
window.dispatchEvent(
new CustomEvent('wework:e2e:runtime-task-lifecycle', {
detail: JSON.parse(command.value ?? '{}'),
})
)
return ''
Comment on lines +762 to +768

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Split wework/src/e2e/automation.ts before adding more command cases.

The file has at least 1,116 lines after this change. Extract a cohesive control-command group into a separate module. Keep executeDesktopControlCommand as the dispatcher.

As per coding guidelines, "split files over 1000 lines."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wework/src/e2e/automation.ts` around lines 762 - 768, Split the cohesive
control-command handling from executeDesktopControlCommand into a separate
module because the automation file exceeds the 1,000-line guideline. Keep
executeDesktopControlCommand as the dispatcher, move the relevant command cases
and their supporting logic together, and delegate to the extracted module
without changing command behavior.

Source: Coding guidelines

case 'storeLocalProxyUrl':
return JSON.stringify(saveLocalProxyUrl(command.value?.trim() ?? ''))
case 'getLocalStorageItem':
Expand Down
8 changes: 4 additions & 4 deletions wework/src/features/workbench/WorkbenchProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1244,13 +1244,13 @@ export function WorkbenchProvider({
createRuntimeConversationStreamHandlers({
onMessageAction: applyCanonicalRuntimeAction,
onGuidanceApplied: settleCanonicalRuntimeGuidance,
onAssistantStart: address => {
onAssistantStart: (address, turnId) => {
markRuntimeConversationAssistantStarted(address)
lifecycleStore.turnStarted(address)
lifecycleStore.turnStarted(address, turnId)
},
onAssistantSettled: address => {
onAssistantSettled: (address, turnId) => {
settleRuntimeConversationSubagents(address)
lifecycleStore.turnSettled(address)
lifecycleStore.turnSettled(address, turnId)
},
onContextUsageUpdated: updateCanonicalRuntimeContextUsage,
onSubagentActivity: applyRuntimeConversationSubagentActivity,
Expand Down
20 changes: 10 additions & 10 deletions wework/src/features/workbench/runtimePaneMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export type RuntimePaneMessageAction = WorkbenchMessageAction<Attachment, TurnFi

export interface RuntimeTaskStreamHandlers {
onMessageAction: (action: RuntimePaneMessageAction) => void
onAssistantStart?: () => void
onAssistantSettled?: () => void
onAssistantStart?: (turnId: string) => void
onAssistantSettled?: (turnId: string) => void
onRefreshWorkLists?: () => void
onContextUsageUpdated?: (usage: RuntimeContextUsage) => void
onSubagentActivity?: (payload: RuntimeSubagentActivityPayload) => void
Expand All @@ -54,8 +54,8 @@ export interface RuntimeTaskStreamHandlers {

export interface RuntimeConversationStreamHandlers {
onMessageAction: (address: RuntimeTaskAddress, action: RuntimePaneMessageAction) => void
onAssistantStart?: (address: RuntimeTaskAddress) => void
onAssistantSettled?: (address: RuntimeTaskAddress) => void
onAssistantStart?: (address: RuntimeTaskAddress, turnId: string) => void
onAssistantSettled?: (address: RuntimeTaskAddress, turnId: string) => void
onRefreshWorkLists?: (address: RuntimeTaskAddress) => void
onContextUsageUpdated?: (address: RuntimeTaskAddress, usage: RuntimeContextUsage) => void
onSubagentActivity?: (
Expand Down Expand Up @@ -100,8 +100,8 @@ export function createRuntimeConversationStreamHandlers(

const created = createRuntimeTaskStreamHandlers(address, {
onMessageAction: action => handlers.onMessageAction(address, action),
onAssistantStart: () => handlers.onAssistantStart?.(address),
onAssistantSettled: () => handlers.onAssistantSettled?.(address),
onAssistantStart: turnId => handlers.onAssistantStart?.(address, turnId),
onAssistantSettled: turnId => handlers.onAssistantSettled?.(address, turnId),
onRefreshWorkLists: () => handlers.onRefreshWorkLists?.(address),
onContextUsageUpdated: usage => handlers.onContextUsageUpdated?.(address, usage),
onSubagentActivity: payload => handlers.onSubagentActivity?.(address, payload),
Expand Down Expand Up @@ -168,7 +168,7 @@ export function createRuntimeTaskStreamHandlers(
return
}
debugRuntimeStreamEvent('chat:start', address, payload, true)
handlers.onAssistantStart?.()
handlers.onAssistantStart?.(identity.subtaskId)
handlers.onMessageAction({
type: 'assistant_started',
taskId: payload.taskId,
Expand Down Expand Up @@ -266,7 +266,7 @@ export function createRuntimeTaskStreamHandlers(
blocks,
fileChanges,
})
handlers.onAssistantSettled?.()
handlers.onAssistantSettled?.(identity.subtaskId)
handlers.onRefreshWorkLists?.()
},
onChatError: payload => {
Expand Down Expand Up @@ -308,7 +308,7 @@ export function createRuntimeTaskStreamHandlers(
errorType: payload.type,
})
}
handlers.onAssistantSettled?.()
handlers.onAssistantSettled?.(identity.subtaskId)
streamedFileChanges.delete(identity.subtaskId)
handlers.onRefreshWorkLists?.()
},
Expand Down Expand Up @@ -345,7 +345,7 @@ export function createRuntimeTaskStreamHandlers(
type: 'assistant_done',
subtaskId: identity.subtaskId,
})
handlers.onAssistantSettled?.()
handlers.onAssistantSettled?.(identity.subtaskId)
handlers.onRefreshWorkLists?.()
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
import type { ReactNode } from 'react'
import { useEffect, type ReactNode } from 'react'
import type { RuntimeTaskAddress } from '@/types/api'
import { RuntimeTaskLifecycleContext } from './internalContext'
import type { RuntimeTaskLifecycleStore } from './RuntimeTaskLifecycleStore'

const E2E_RUNTIME_LIFECYCLE_EVENT = 'wework:e2e:runtime-task-lifecycle'

export function RuntimeTaskLifecycleProvider({
store,
children,
}: {
store: RuntimeTaskLifecycleStore
children: ReactNode
}) {
useEffect(() => {
if (import.meta.env.VITE_WEWORK_E2E !== 'true') return

const handleLifecycleEvent = (event: Event) => {
const detail = (
event as CustomEvent<{
address: RuntimeTaskAddress
type: string
turnId?: string | null
}>
).detail
if (detail?.type === 'turn_settled') {
store.turnSettled(detail.address, detail.turnId)
}
}
window.addEventListener(E2E_RUNTIME_LIFECYCLE_EVENT, handleLifecycleEvent)
return () => window.removeEventListener(E2E_RUNTIME_LIFECYCLE_EVENT, handleLifecycleEvent)
}, [store])

return (
<RuntimeTaskLifecycleContext.Provider value={store}>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,32 @@ describe('RuntimeTaskLifecycleStore', () => {
expect(store.getTask(address)?.execution.running).toBe(false)
})

test('ignores a settled event from an older turn after a newer turn starts', () => {
const store = new RuntimeTaskLifecycleStore('test')
store.turnStarted(address, 'turn-1')
store.turnStarted(address, 'turn-2')

store.turnSettled(address, 'turn-1')

const snapshot = store.getTask(address)
expect(snapshot?.execution.running).toBe(true)
expect(snapshot?.turn.phase).toBe('streaming')
expect(snapshot?.derived.shouldShowSidebarRunning).toBe(true)
})

test('keeps a live stream when an idle executor snapshot lags behind it', () => {
const store = new RuntimeTaskLifecycleStore('test')
store.syncRuntimeWork(runtimeWork(task({ running: false })))

store.turnStarted(address)
store.syncRuntimeWork(runtimeWork(task({ running: false, status: 'active' })))

const snapshot = store.getTask(address)
expect(snapshot?.execution.phase).toBe('running')
expect(snapshot?.turn.phase).toBe('streaming')
expect(snapshot?.derived.shouldShowSidebarRunning).toBe(true)
})

test('marks only background non-Goal completion unread and clears it when opened', () => {
const store = new RuntimeTaskLifecycleStore('test')
store.syncRuntimeWork(runtimeWork(task({ running: true })))
Expand Down
Loading
Loading