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
9 changes: 9 additions & 0 deletions .github/scripts/test-classify-ci-changes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/wework-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/en/wework/developer-guide/wework-chat-state-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ A reference is serialized in the draft as `[$title](wework-conversation://<encod

This feature is a user-authorized pre-send snapshot injection, not a conversation MCP. The model cannot independently list, search, or read conversations that the user did not reference; menu search uses only metadata already loaded in `runtimeWork`. Changes to this path must keep the reference parsing and context construction unit tests, composer/message rendering tests, and the `conversation-mention.scenario.mjs` desktop E2E scenario aligned.

## Conversation Switching and Transcript Restoration

`loadedRuntimeTranscriptKeyRef` only proves that a task transcript finished loading at some point. It does not prove that the message area is still displaying that task. When the user rapidly switches from task A to a still-loading task B and back to A, B's cached messages may already have replaced the message area while A remains the last successfully loaded key.

The pane may therefore skip restoration only when both the loaded key and the displayed transcript identity match the target task. When the identities differ, it must reapply the target task's cached messages and start a transcript load. Effect cleanup must continue isolating late responses from other tasks so they cannot overwrite the current task.

Cover this path with both a component race test and a real desktop scenario: keep one task running, switch rapidly between it and a completed task, then verify that every historical turn in the completed task remains visible after switching back.

## Long Output Memory Boundary

The Wework chat UI must not keep complete long-running output in React state. `WorkbenchMessage.content`, thinking/text/plan block `content`, and tool block `toolOutput` must enter `messages` through the shared preview-window path:
Expand Down
8 changes: 8 additions & 0 deletions docs/zh/wework/developer-guide/wework-chat-state-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ Composer 的 `@` 菜单支持显式引用其他 Wework 会话。空查询展示

该能力是用户授权后的发送前快照注入,不是会话 MCP。模型不能自行列出、搜索或读取未被用户引用的其他会话;菜单搜索也只使用已经加载到 `runtimeWork` 的元数据。修改该路径时,必须同时维护引用解析与上下文构造单元测试、composer/消息渲染测试,以及 `conversation-mention.scenario.mjs` 桌面 E2E 场景。

## 会话切换与 Transcript 恢复

`loadedRuntimeTranscriptKeyRef` 只表示某个任务的 transcript 曾成功加载,不能单独证明当前消息区仍在展示该任务。快速从任务 A 切到仍在加载的任务 B,再切回 A 时,B 的缓存消息可能已经替换消息区,而最后完成加载的 key 仍然是 A。

因此,只有已加载 key 和当前展示的 transcript 身份同时匹配目标任务时,pane 才能跳过恢复。身份不一致时必须重新应用目标任务的缓存消息并启动 transcript 加载;迟到的其他任务响应仍需由 effect cleanup 隔离,不能覆盖当前任务。

这条链路必须同时覆盖组件竞态测试和真实桌面场景:保持一个任务运行,在已完成任务与运行中任务之间快速切换,切回后确认已完成任务的所有历史轮次仍然可见。

## 长输出内存边界

Wework 的聊天 UI 不能把持续输出的完整正文长期保存在 React state 中。`WorkbenchMessage.content`、thinking/text/plan block 的 `content`、tool block 的 `toolOutput` 都必须通过统一的预览窗口进入 `messages`:
Expand Down
108 changes: 88 additions & 20 deletions wework/e2e/desktop/task-flow.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ const CLOUD_PUBLIC_MODEL_LABEL = 'Desktop E2E Public Model'
const CLOUD_DEVICE_ID = 'wework-e2e-cloud-device'
const FRESH_CHAT_PROMPT = 'WEWORK_DESKTOP_E2E_FRESH_CHAT: confirm this is a new conversation.'
const FRESH_CHAT_COMPLETION_TEXT = 'WEWORK_DESKTOP_E2E_FRESH_CHAT_COMPLETE'
const CONVERSATION_SWITCH_RACE_PROMPT =
'WEWORK_DESKTOP_E2E_CONCURRENT_MEMORY_1: keep a second conversation running during a rapid switch.'
const SHORT_CONVERSATION_MAX_MESSAGE_TOP_OFFSET = 160
const COMPOSER_PROJECT_NAME = 'Composer Flow Project'
const ATTACHMENT_ONLY_COMPLETION_TEXT = 'WEWORK_DESKTOP_E2E_ATTACHMENT_ONLY_COMPLETE'
Expand Down Expand Up @@ -1556,6 +1558,88 @@ async function verifyShortConversationLayout({ composerSelector, control }) {
messageTopOffset <= SHORT_CONVERSATION_MAX_MESSAGE_TOP_OFFSET,
`The short conversation left ${messageTopOffset}px of blank space above its first message`
)

const taskRowsBeforeRace = new Set(
JSON.parse(await control.command('snapshot', 'body')).testIds.filter(testId =>
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'
)
Comment thread
qdaxb marked this conversation as resolved.
Resolved
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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion wework/src/components/layout/useWorkbenchPaneSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
20 changes: 20 additions & 0 deletions wework/src/e2e/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions wework/src/features/workbench/WorkbenchProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeTranscriptResponse>()
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(<RuntimeOpenProbe />, 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',
Expand Down
Loading