From 6a186fbee3a7a900d5c375b06c58e8071d8ce9bf Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 16:27:32 +0800 Subject: [PATCH 1/4] test(desktop): settle queue admissions before editing in side-chat spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec pressed Enter on the third follow-up and clicked edit on the first within ~100ms. beginEdit captures the queue revision at click time, so when the third entry's Host admission landed inside that window the update carried a stale expectedQueueRevision and was correctly rejected with operation_conflict. The failed commit leaves the edit textarea open, which replaces the row's queue-text span — the queue never reordered; the first entry was simply hidden behind its own edit box while the error toast reported the conflict. Wait for the third entry's edit button to become enabled — enabled marks Host-owned 'queued' state — before opening the edit, matching the spec's own note that optimistic appearance does not settle send admission. Generated-by: Devin --- apps/desktop/e2e/side-chat-followups.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/e2e/side-chat-followups.spec.ts b/apps/desktop/e2e/side-chat-followups.spec.ts index bed9262c6b..35cb81f619 100644 --- a/apps/desktop/e2e/side-chat-followups.spec.ts +++ b/apps/desktop/e2e/side-chat-followups.spec.ts @@ -115,7 +115,12 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ 'first follow-up', 'second follow-up', 'retract this follow-up', ]); - await queued.getByRole('button', { name: '编辑', exact: true }).first().click(); + // beginEdit captures the queue revision, so every entry must already be + // Host-owned ('queued' enables its edit button); a pending admission would + // bump the revision and reject the update as a conflict. + const editButtons = queued.getByRole('button', { name: '编辑', exact: true }); + await expect(editButtons.nth(2)).toBeEnabled(); + await editButtons.first().click(); const edit = queued.getByRole('textbox', { name: '编辑', exact: true }); await edit.fill('edited first follow-up'); await edit.press('Enter'); From 1a9ccc97816cf07d9c9dbd483ced2d985e68df99 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 16:27:40 +0800 Subject: [PATCH 2/4] fix(desktop): own the native menu through its popup lifetime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI runs died mid-assertion after closePopup(): the main window's CDP session closed because the native menu teardown crashed the process. popupNativeMenu never retained the Menu it built, so a JS wrapper collected while its popup is open can crash the native close path (electron#20737 family). Hold each open menu until its popup callback reports it closed. The spec also closed the popup unconditionally. On Linux a popup can auto-dismiss after window resizes — this spec resizes three times first — and closing an already-dead popup hits the same teardown crash. aria-expanded tracks the popup IPC resolution, so only call closePopup while it still reports the menu open. Renderer crashes previously left no artifact evidence; the fixture now logs every page crash, including the restarted window's, into the error context. Generated-by: Devin --- apps/desktop/e2e/fixtures.ts | 11 +++++++++++ apps/desktop/e2e/workhub-layout.spec.ts | 7 ++++++- apps/desktop/src/main/native-menu.ts | 14 ++++++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 114059b51b..0cf84a300f 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -507,6 +507,12 @@ export async function withE2eWindow( rendererLogs.push(`[pageerror] ${error.stack ?? error.message}`); if (rendererLogs.length > 30) rendererLogs.shift(); }); + const watchCrash = (crashed: Page) => crashed.on('crash', () => { + rendererLogs.push(`[crash] ${crashed.url()}`); + if (rendererLogs.length > 30) rendererLogs.shift(); + }); + for (const existing of app.context().pages()) watchCrash(existing); + app.context().on('page', watchCrash); if (tracePath) { await mkdir(path.dirname(tracePath), { recursive: true }); await app.context().tracing.start({ snapshots: true }); @@ -533,6 +539,11 @@ export async function withE2eWindow( env: buildFixtureEnv(userDataDir, homeDir, { scenario: e2eFixtureScenario, locale, platform, showWindow: visibleWindow }), }); const restored = await app.firstWindow(); + restored.on('crash', () => { + rendererLogs.push(`[crash] ${restored.url()}`); + if (rendererLogs.length > 30) rendererLogs.shift(); + }); + app.context().on('page', watchCrash); await restored.waitForSelector(readinessSelector, { timeout: readinessTimeoutMs }); return restored; } }); diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index b8264fd3e9..02ea00d98d 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -95,7 +95,12 @@ test('WorkHub uses its coordination model and shared attachment composer', async 'webContents' in child && (child as Electron.WebContentsView).webContents.getURL().includes('surface=workhub'))); return container?.getVisible(); })).toBe(true); - await app.evaluate(() => (globalThis as unknown as { workbarMenu: Electron.Menu }).workbarMenu.closePopup()); + // aria-expanded tracks the popup IPC resolution, so a menu that already + // auto-dismissed (Linux closes popups after window resizes) must not be + // closed again — closePopup on a dead popup crashes the main process. + if ((await addPanel.getAttribute('aria-expanded')) === 'true') { + await app.evaluate(() => (globalThis as unknown as { workbarMenu: Electron.Menu }).workbarMenu.closePopup()); + } await expect(addPanel).toHaveAttribute('aria-expanded', 'false'); await workhub.getByRole('button', { name: '收起任务工作栏', exact: true }).click(); await expect(page.locator('.maka-session-workbar[data-placement="right"]')).toBeHidden(); diff --git a/apps/desktop/src/main/native-menu.ts b/apps/desktop/src/main/native-menu.ts index 95104405b6..a31c59a9e3 100644 --- a/apps/desktop/src/main/native-menu.ts +++ b/apps/desktop/src/main/native-menu.ts @@ -20,6 +20,10 @@ import { Menu, type BrowserWindow } from 'electron'; import type { NativeMenuRequest } from '../shared/native-menu.js'; +// A Menu collected while its popup is still open crashes the native close +// path, so each popup owns a reference until its callback reports it closed. +const openMenus = new Set(); + export function popupNativeMenu(window: BrowserWindow, input: unknown): Promise { const request = input as NativeMenuRequest | undefined; if (!request || !Number.isFinite(request.x) || !Number.isFinite(request.y) || @@ -38,7 +42,13 @@ export function popupNativeMenu(window: BrowserWindow, input: unknown): Promise< }))); // Menu coordinates are relative to window content, in DIP rather than CSS pixels. const zoom = window.webContents.getZoomFactor(); - menu.popup({ window, x: Math.round(request.x * zoom), y: Math.round(request.y * zoom), - callback: () => resolve(selected) }); + openMenus.add(menu); + try { + menu.popup({ window, x: Math.round(request.x * zoom), y: Math.round(request.y * zoom), + callback: () => { openMenus.delete(menu); resolve(selected); } }); + } catch (error) { + openMenus.delete(menu); + throw error; + } }); } From 453e2bb09af63eaf4ca445b7987385a0c64a2254 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 19:35:29 +0800 Subject: [PATCH 3/4] test(desktop): move queue row semantics to component tests The side-chat E2E asserted ComposerMessageQueue contracts through a real Electron window: the revision captured at edit click, a rejected stale-revision update leaving the row in edit mode (the misread that hid the edited row behind its own textarea), and drag reorder passing the Host-owned id list. All three are renderer-owned and now run in packages/ui against the real component with stubbed callbacks. Mutation-checked: closing edit mode unconditionally on a rejected update fails the stale-revision test. Generated-by: Devin --- .../__tests__/composer-message-queue.test.tsx | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 packages/ui/src/__tests__/composer-message-queue.test.tsx diff --git a/packages/ui/src/__tests__/composer-message-queue.test.tsx b/packages/ui/src/__tests__/composer-message-queue.test.tsx new file mode 100644 index 0000000000..f6f4dfd714 --- /dev/null +++ b/packages/ui/src/__tests__/composer-message-queue.test.tsx @@ -0,0 +1,205 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The pending plate's own contract. The E2E side-chat failure this replaces + * hid a queued row behind its own still-open edit box after the Host rejected + * a stale-revision update, so the row list silently read as reordered. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { act, createElement } from 'react'; +import type { MessageQueueEntryProjection } from '@maka/core/events'; +import { getConversationCopy } from '../conversation-copy.js'; +import { installDom } from './mermaid-test-dom.js'; + +const copy = getConversationCopy('en').composer; + +function queued(entryId: string, text: string): MessageQueueEntryProjection { + return { + entryId, + messageId: `msg-${entryId}`, + content: { text }, + placement: 'next_turn', + state: 'queued', + }; +} + +async function mountQueue(props: { + queuedMessages: readonly MessageQueueEntryProjection[]; + queueRevision?: number; + onUpdateEntry?(entryId: string, expectedQueueRevision: number, text: string): void | Promise; + onDeleteEntry?(entryId: string): void | Promise; + onPromoteEntry?(entryId: string): void | Promise; + onReorderEntries?(entryIds: readonly string[]): void | Promise; +}) { + const dom = installDom(); + const { createRoot } = await import('react-dom/client'); + const { ComposerMessageQueue } = await import('../composer-message-queue.js'); + const root = createRoot(dom.document.getElementById('root')!); + const render = (next: typeof props) => + root.render(createElement(ComposerMessageQueue, { ...next, copy })); + await act(async () => render(props)); + return { + document: dom.document, + async rerender(next: typeof props) { + await act(async () => render(next)); + }, + async close() { + await act(async () => root.unmount()); + dom.restore(); + }, + }; +} + +function queueTexts(document: Document): string[] { + return [...document.querySelectorAll('.maka-composer-queue-text')].map( + (element) => element.textContent ?? '', + ); +} + +function actionButton(document: Document, label: string, index = 0): HTMLButtonElement { + const buttons = [...document.querySelectorAll('button')].filter( + (button) => (button.getAttribute('aria-label') ?? button.textContent) === label, + ); + assert.ok(buttons[index], `expected a ${label} action at index ${index}`); + return buttons[index]!; +} + +async function click(button: HTMLButtonElement): Promise { + await act(async () => { + button.dispatchEvent(new window.Event('click', { bubbles: true })); + }); +} + +test('editing a queued entry reports the captured queue revision and closes on success', async () => { + const updates: Array<{ entryId: string; revision: number; text: string }> = []; + const view = await mountQueue({ + queuedMessages: [queued('entry-1', 'first follow-up'), queued('entry-2', 'second follow-up')], + queueRevision: 7, + onUpdateEntry: (entryId, expectedQueueRevision, text) => { + updates.push({ entryId, revision: expectedQueueRevision, text }); + }, + }); + try { + await click(actionButton(view.document, copy.editQueuedEntry, 0)); + const editor = view.document.querySelector('textarea.maka-composer-queue-edit'); + assert.ok(editor, 'beginEdit swaps the row into its textarea'); + assert.equal(editor.value, 'first follow-up'); + await view.rerender({ + queuedMessages: [queued('entry-1', 'first follow-up'), queued('entry-2', 'second follow-up')], + queueRevision: 8, + onUpdateEntry: (entryId, expectedQueueRevision, text) => { + updates.push({ entryId, revision: expectedQueueRevision, text }); + }, + }); + await act(async () => { + editor.value = 'edited first follow-up'; + editor.dispatchEvent(new window.Event('input', { bubbles: true })); + }); + await click(actionButton(view.document, copy.saveQueuedEntry)); + assert.deepEqual(updates, [{ entryId: 'entry-1', revision: 7, text: 'edited first follow-up' }]); + assert.equal(view.document.querySelector('textarea.maka-composer-queue-edit'), null); + assert.deepEqual(queueTexts(view.document), ['first follow-up', 'second follow-up']); + } finally { + await view.close(); + } +}); + +test('a rejected queue edit keeps the row in edit mode instead of reading as reordered', async () => { + const view = await mountQueue({ + queuedMessages: [ + queued('entry-1', 'first follow-up'), + queued('entry-2', 'second follow-up'), + queued('entry-3', 'retract this follow-up'), + ], + queueRevision: 3, + onUpdateEntry: () => Promise.reject(new Error('operation_conflict')), + }); + try { + await click(actionButton(view.document, copy.editQueuedEntry, 0)); + const editor = view.document.querySelector('textarea.maka-composer-queue-edit')!; + await act(async () => { + editor.value = 'edited first follow-up'; + editor.dispatchEvent(new window.Event('input', { bubbles: true })); + }); + await click(actionButton(view.document, copy.saveQueuedEntry)); + assert.deepEqual( + queueTexts(view.document), + ['second follow-up', 'retract this follow-up'], + 'the conflicted row is hidden behind its open editor, not dropped or moved', + ); + assert.equal( + view.document.querySelector('textarea.maka-composer-queue-edit')?.value, + 'edited first follow-up', + ); + } finally { + await view.close(); + } +}); + +test('queue actions stay disabled until the entry is Host-admitted', async () => { + const pending: MessageQueueEntryProjection[] = [ + { ...queued('entry-1', 'first follow-up'), state: 'in_flight' }, + queued('entry-2', 'second follow-up'), + ]; + const view = await mountQueue({ + queuedMessages: pending, + queueRevision: undefined, + onUpdateEntry: () => {}, + onDeleteEntry: () => {}, + }); + try { + const edits = [...view.document.querySelectorAll('button')].filter( + (button) => (button.getAttribute('aria-label') ?? button.textContent) === copy.editQueuedEntry, + ); + assert.equal(edits.length, 2); + assert.ok(edits.every((button) => button.disabled), 'no row is editable without a queue revision'); + } finally { + await view.close(); + } +}); + +test('dragging reorders the Host-owned id list', async () => { + const reordered: string[][] = []; + const view = await mountQueue({ + queuedMessages: [ + queued('entry-1', 'first follow-up'), + queued('entry-2', 'second follow-up'), + queued('entry-3', 'retract this follow-up'), + ], + queueRevision: 1, + onReorderEntries: (ids) => { reordered.push([...ids]); }, + }); + try { + const grips = view.document.querySelectorAll('[draggable="true"]'); + const source = grips[1]!; + const target = view.document.querySelectorAll('[data-maka-queue-drop-target="true"]')[0]!; + await act(async () => { + source.dispatchEvent(Object.assign(new window.Event('dragstart', { bubbles: true }), { + dataTransfer: { effectAllowed: '', setData: () => {} }, + })); + target.dispatchEvent(new window.Event('drop', { bubbles: true })); + }); + assert.deepEqual(reordered, [['entry-2', 'entry-1', 'entry-3']]); + } finally { + await view.close(); + } +}); From dc3be31f6590e37228761d4fa5d198dfe6958dbc Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 21 Sep 2026 19:35:29 +0800 Subject: [PATCH 4/4] test(desktop): migrate skill-draft lifecycle coverage to action seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deleted E2E asserted renderer-owned contracts: a refused send keeps the draft, a retry reuses the already-prepared child instead of forking another revision, and cancel restores the pre-edit draft text (Skill token included). They now run deterministically against createAppShellRevisionActions with a stubbed bridge, plus the blocked- Skill toast through createAppShellChatActions. The transcript-settlement gate that once raced the deferred React handoff was removed upstream in #5494, which already covers it with a no-second-transcript-open test. The success-path draft cleanup stays inline in sendWithAttachments — untested glue, same as before — rather than earning a new seam here. Mutation-checked: removing the retry early-return and restoring the wrong draft text each fail their test. Generated-by: Devin --- apps/desktop/e2e-budget.json | 6 +- apps/desktop/e2e/fixtures.ts | 22 --- apps/desktop/e2e/side-chat-followups.spec.ts | 74 ++------- .../desktop/e2e/skill-draft-lifecycle.spec.ts | 147 ------------------ .../app-shell-busy-race-settlement.test.ts | 30 ++++ .../app-shell-revision-actions.test.ts | 115 +++++++++++++- 6 files changed, 161 insertions(+), 233 deletions(-) delete mode 100644 apps/desktop/e2e/skill-draft-lifecycle.spec.ts diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index a1ede11687..42fe0efc73 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -59,11 +59,7 @@ }, "side-chat-followups.spec.ts": { "tests": 1, - "electron": "queue mutations and successive Side Chat Turns cross renderer/preload/main/Host; native dragging must pass the main-window drop guard and Host reconnect must restore the live fork without remounting it" - }, - "skill-draft-lifecycle.spec.ts": { - "tests": 2, - "electron": "revision retry and cancel are Host-owned draft transitions across a parent and a child Session" + "electron": "native queue dragging must pass the main-window drop guard, queued follow-ups drain in order across real Host turn handoffs, and closing the Desktop transport while an external client drives the fork exercises observation reseed without remount" }, "slash-command-compact.spec.ts": { "tests": 1, diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 0cf84a300f..f384bafab8 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -116,28 +116,6 @@ export async function waitForInvocableSkills( .toEqual(expect.arrayContaining(expectedIds)); } -/** - * Wait for Runtime's projection to stop offering a Skill. - * - * A Skill is toggled through the raw bridge here rather than the Skills page, so - * nothing re-fetches the composer's `/` source on its own. Pressing Enter before - * Runtime has dropped the Skill lets the send resolve it and succeed, and the - * rejection the journey expects never renders — the composer keeps offering a - * Skill that is already disabled. - */ -export async function waitForSkillNotInvocable( - page: Page, - absentIds: readonly string[], -): Promise { - await expect - .poll(async () => - page.evaluate(async () => - (await window.maka.skills.listInvocable(undefined)).map((skill) => skill.id), - ), - ) - .not.toEqual(expect.arrayContaining(absentIds)); -} - /** * Pre-seed a real-looking connection into the throwaway workspace so onboarding * clears and the composer is enabled. Actual sessions still run on the fake diff --git a/apps/desktop/e2e/side-chat-followups.spec.ts b/apps/desktop/e2e/side-chat-followups.spec.ts index 35cb81f619..b4fb16255c 100644 --- a/apps/desktop/e2e/side-chat-followups.spec.ts +++ b/apps/desktop/e2e/side-chat-followups.spec.ts @@ -17,7 +17,7 @@ * under the License. */ -import { FAKE_HOLD_OPEN_PROMPT, FAKE_WAIT_FOR_STEERING_PROMPT } from '@maka/runtime/test-only/fake-backend'; +import { FAKE_WAIT_FOR_STEERING_PROMPT } from '@maka/runtime/test-only/fake-backend'; import { connectExistingRuntimeHost } from '@maka/runtime-host/client'; import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol'; import type { ElectronApplication } from '@playwright/test'; @@ -74,9 +74,10 @@ async function armConnectionGap(app: ElectronApplication): Promise { // The real main-window capture listener previously swallowed queue drops, and // the restored main/preload observation left completed entries in this panel. -// Component/Host tests omit those Electron owners; this one window verifies -// their wiring while the existing hook/projector suites cover state orderings. -test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', async ({}, testInfo) => { +// Row-level edit/delete/promote semantics live in the ComposerMessageQueue +// component tests now; this window keeps the native drop guard, the queue's +// drain across Host turn handoffs, and the transport reconnect journey. +test('Side Chat queue survives a native reorder and a Desktop reconnect', async ({}, testInfo) => { await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, @@ -95,10 +96,10 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', await page.getByRole('button', { name: /侧边对话.*在不打断主任务的情况下追问和只读探索/ }).click(); const companion = page.locator('.maka-quote-companion'); const sideComposer = companion.locator(COMPOSER_INPUT); - await sideComposer.fill(FAKE_HOLD_OPEN_PROMPT); + await sideComposer.fill(FAKE_WAIT_FOR_STEERING_PROMPT); await awaitSendReady(companion); await sideComposer.press('Enter'); - await expect(companion).toContainText('Fake backend waiting'); + await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible(); const forkId = await page.evaluate(async (existingIds) => { const created = (await window.maka.sessions.list()).filter((session) => !existingIds.includes(session.id)); if (created.length !== 1) throw new Error(`Expected one Side Chat fork, found ${created.length}`); @@ -112,66 +113,23 @@ test('Side Chat follow-ups survive queue actions, Host handoffs and reconnect', await sideComposer.press('Enter'); await expect(queued).toContainText(text); } - await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ - 'first follow-up', 'second follow-up', 'retract this follow-up', - ]); - // beginEdit captures the queue revision, so every entry must already be - // Host-owned ('queued' enables its edit button); a pending admission would - // bump the revision and reject the update as a conflict. - const editButtons = queued.getByRole('button', { name: '编辑', exact: true }); - await expect(editButtons.nth(2)).toBeEnabled(); - await editButtons.first().click(); - const edit = queued.getByRole('textbox', { name: '编辑', exact: true }); - await edit.fill('edited first follow-up'); - await edit.press('Enter'); - await expect(queued.locator('.maka-composer-queue-text').first()).toHaveText('edited first follow-up'); + // A drag only reorders Host-owned ('queued') entries; a still-pending + // admission has no draggable grip, so enabled edit buttons settle it. + await expect(queued.getByRole('button', { name: '编辑', exact: true }).nth(2)).toBeEnabled(); const grips = queued.locator('[draggable="true"]'); await grips.nth(1).dragTo(grips.nth(0)); await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ - 'second follow-up', 'edited first follow-up', 'retract this follow-up', + 'second follow-up', 'first follow-up', 'retract this follow-up', ]); - await queued.getByRole('button', { name: '删除', exact: true }).nth(2).click(); - await expect(queued).not.toContainText('retract this follow-up'); - await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible(); - - await sideComposer.fill('steer the current response'); - await awaitSendReady(companion); - await sideComposer.press('Shift+Enter'); - await expect(companion).toContainText('Acknowledged steering: steer the current response'); - await expect(queued.locator('.maka-composer-queue-text')).toHaveText([ - 'second follow-up', 'edited first follow-up', - ]); - await queued.getByRole('button', { name: '调整方向', exact: true }).first().click(); - await expect(companion.locator('.maka-steering-message').last()).toContainText('second follow-up'); - await expect(queued.locator('.maka-composer-queue-text')).toHaveText(['edited first follow-up']); - await queued.getByRole('button', { name: '删除', exact: true }).click(); - await expect(queued).toHaveCount(0); - await page.screenshot({ path: testInfo.outputPath('side-chat-steering.png'), fullPage: true }); - await companion.getByRole('button', { name: '停止', exact: true }).click(); - await expect(companion.getByRole('button', { name: '停止', exact: true })).toHaveCount(0, { timeout: 20_000 }); - // The held-open fixture's pipe-separated acknowledgment is an unfinished - // Markdown table candidate until Stop flushes the final assistant message. - await expect(companion).toContainText('steer the current response | second follow-up'); - - // Hold a second Turn before its first token, queue two successors, then - // release it by steering. All three replies must survive the Host handoffs. - await sideComposer.fill(FAKE_WAIT_FOR_STEERING_PROMPT); - await awaitSendReady(companion); - await sideComposer.press('Enter'); - await expect(companion.getByRole('button', { name: '停止', exact: true })).toBeVisible(); - for (const text of ['successor one', 'successor two']) { - await sideComposer.fill(text); - await awaitSendReady(companion); - await sideComposer.press('Enter'); - await expect(queued).toContainText(text); - } - await page.screenshot({ path: testInfo.outputPath('side-chat-queue.png'), fullPage: true }); + // Steering releases the held Turn; the reordered queue then drains into + // its own Turns in the Host-observed order and the panel clears. await sideComposer.fill('release the held response'); await awaitSendReady(companion); await sideComposer.press('Shift+Enter'); await expect(companion).toContainText('Acknowledged steering: release the held response'); - await expect(companion).toContainText('Fake backend received: successor one', { timeout: 20_000 }); - await expect(companion).toContainText('Fake backend received: successor two', { timeout: 20_000 }); + await expect(companion).toContainText('Fake backend received: second follow-up', { timeout: 20_000 }); + await expect(companion).toContainText('Fake backend received: first follow-up'); + await expect(companion).toContainText('Fake backend received: retract this follow-up'); await expect(companion.getByRole('button', { name: '停止', exact: true })).toHaveCount(0, { timeout: 20_000 }); await expect(queued).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath('side-chat-settled.png'), fullPage: true }); diff --git a/apps/desktop/e2e/skill-draft-lifecycle.spec.ts b/apps/desktop/e2e/skill-draft-lifecycle.spec.ts deleted file mode 100644 index 80c9ef1e93..0000000000 --- a/apps/desktop/e2e/skill-draft-lifecycle.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { Page } from '@playwright/test'; -import { expect, test, COMPOSER_INPUT, waitForSkillNotInvocable } from './fixtures'; - -/** - * Revision drafts, per session, with a Skill staged in them. - * - * A staged Skill is a `/skill:` chip inside the draft text, so every path - * here — begin edit, prepare the branch, fail the send, cancel back — moves it - * by moving the text. The point of these journeys is that nothing has to carry - * the Skill separately for that to hold. - * - * The Skill itself comes from the real catalog (the invocable-skills fixture - * plus the Skills module page), not a Desktop-only starter IPC. - */ -async function openInstalledWorkspaceSkill(page: Page): Promise { - await page.getByRole('button', { name: '展开侧边栏' }).click(); - const sidebar = page.getByRole('navigation', { name: '任务列表' }); - await sidebar.getByRole('button', { name: '扩展' }).click(); - await expect(page.locator('[data-module="skills"]')).toBeVisible(); - await expect(page.getByText('Workspace Only', { exact: true })).toBeVisible(); - await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); - await expect(page.locator(COMPOSER_INPUT)).toBeVisible(); -} - -async function seedEditableTurn(page: Page): Promise { - const firstSend = page.locator(COMPOSER_INPUT); - await firstSend.fill('original message'); - await firstSend.press('Enter'); - await expect(page.getByText(/Fake backend received: original message/)).toBeVisible(); -} - -/** Type the draft, then append the Skill chip — the order a user works in. */ -async function composeWithSkill(page: Page, text: string, name: RegExp): Promise { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill(text); - await composer.click(); - await composer.pressSequentially(' /'); - const option = page.getByRole('listbox', { name: /技能/ }).getByRole('option', { name }); - await expect(option).toBeVisible(); - await option.click(); -} - -async function beginRevision(page: Page): Promise { - const userMessage = page.getByLabel('你发送的消息').first(); - await userMessage.hover(); - await userMessage.getByRole('button', { name: '编辑并重发' }).click(); - await expect(page.locator('[data-revision-notice="true"]')).toBeVisible(); -} - -async function failWorkspaceSkillRevision(page: Page): Promise { - const disabled = await page.evaluate(() => - window.maka.skills.setEnabled('workspace-only', false), - ); - expect(disabled.ok).toBe(true); - - const composer = page.locator(COMPOSER_INPUT); - // Two independent races sit between disabling the Skill and pressing Enter, - // and both make the rejection this journey asserts never render: - // - // 1. The toggle above goes through the raw bridge, not the Skills page, so - // nothing re-fetches the composer's `/` source. Until Runtime's projection - // drops the Skill it is still invocable, the send resolves it, and it - // succeeds — no banner. - // 2. Picking the Skill left the `/` menu open. While it reports - // `aria-expanded`, the composer swallows Enter as menu acceptance instead - // of sending, so the draft is never submitted at all. - // - // Settle both: the menu closed, and the Skill gone from the invocable set. - await expect(composer).toHaveAttribute('aria-expanded', 'false'); - await waitForSkillNotInvocable(page, ['workspace-only']); - - await composer.press('Enter'); - await expect(page.getByText('Skill 调用失败,消息未发送')).toBeVisible(); - // The draft survives the rejection whole, and reads as the token rather than - // as a chip: the Skill was just disabled, so it is gone from the catalog the - // composer draws chips from. A chip here would promise a Skill that no longer - // resolves — the text is the honest rendering, and re-enabling it below sends. - await expect(composer).toContainText('edited with skill'); - await expect(composer).toContainText('/skill:workspace-only'); -} - -test('a successful revision retry clears both child and source drafts', async ({ - invocableSkillsWindow: page, -}) => { - await openInstalledWorkspaceSkill(page); - await seedEditableTurn(page); - await beginRevision(page); - await composeWithSkill(page, 'edited with skill', /Workspace Only/); - await failWorkspaceSkillRevision(page); - - const enabled = await page.evaluate(() => - window.maka.skills.setEnabled('workspace-only', true), - ); - expect(enabled.ok).toBe(true); - await page.locator(COMPOSER_INPUT).press('Enter'); - - await expect(page.locator('[data-revision-notice="true"]')).toHaveCount(0); - await expect(page.locator(COMPOSER_INPUT)).toHaveText(''); - await page.getByRole('button', { name: '查看上一版本' }).click(); - await expect( - page.getByLabel('你发送的消息').getByText('original message', { exact: true }), - ).toBeVisible(); - await expect(page.locator(COMPOSER_INPUT)).toHaveText(''); -}); - -test('cancelling a failed revision restores the complete pre-edit draft', async ({ - invocableSkillsWindow: page, -}) => { - await openInstalledWorkspaceSkill(page); - await seedEditableTurn(page); - - const composer = page.locator(COMPOSER_INPUT); - await composeWithSkill(page, 'previous unsent draft', /Project Only/); - await beginRevision(page); - await composeWithSkill(page, 'edited with skill', /Workspace Only/); - await failWorkspaceSkillRevision(page); - - await page.getByRole('button', { name: '取消' }).click(); - - await expect(page.locator('[data-revision-notice="true"]')).toHaveCount(0); - // Restored through a single controlled write, which rebuilds the editor from - // the serialized draft; the Skill comes back as a chip because the composer - // redraws it from that text, not because anything carried it separately. - await expect(composer).toContainText('previous unsent draft'); - await expect( - page.locator('[data-astryx-token-value="/skill:project-only"]'), - ).toContainText('Project Only'); -}); diff --git a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts index 2b41c3fb8b..88760c3d4d 100644 --- a/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-busy-race-settlement.test.ts @@ -30,6 +30,7 @@ import { describe, it } from 'node:test'; import type { TransientUserMessageProjection } from '@maka/ui'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; +import { getShellCopy } from '../../renderer/locales/shell-copy.js'; import { createActionsDeps, @@ -193,6 +194,35 @@ describe('busy-raced send settlement', () => { } }); + it('surfaces the blocked-Skill toast on an outright refusal', async () => { + const errors: Array<{ title: string; description?: string }> = []; + const restoreWindow = installWindow({ sessions: { + submitMessage: async () => ({ + ok: false as const, + reason: 'skill_invocation_failed' as const, + skillInvocation: { + loaded: [], + failed: [{ request: 'workspace-only', reason: 'not_found' as const }], + receipts: [], + }, + }), + } }); + try { + const actions = createAppShellChatActions({ + ...createActionsDeps(), + activeIdRef: { current: 'session-a' }, + toastApi: { + error: (title: string, description?: string) => { errors.push({ title, description }); }, + info: () => undefined, + }, + }); + assert.equal(await actions.send('edited with skill /skill:workspace-only'), false); + const copy = getShellCopy('en').chatActions; + assert.deepEqual(errors.map((entry) => entry.title), [copy.skillInvocationBlockedTitle]); + assert.match(errors[0]?.description ?? '', /workspace-only/); + } finally { restoreWindow(); } + }); + it('reports a refused Follow Up as not sent', async () => { const restoreWindow = installWindow({ sessions: { diff --git a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts index 4256e12757..1a6eb60cbf 100644 --- a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts @@ -21,7 +21,11 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { StoredMessage } from '@maka/core/session'; -import { createAppShellRevisionActions } from '../../renderer/app-shell-revision-actions.js'; +import { + createAppShellRevisionActions, + type TurnRevisionDraft, +} from '../../renderer/app-shell-revision-actions.js'; +import { installWindow } from './app-shell-chat-actions-fixture.js'; const SESSION_1 = JSON.stringify(['host-1', 'session-1']); const SESSION_2 = JSON.stringify(['host-1', 'session-2']); @@ -224,3 +228,112 @@ describe('prepareRevisionSend transcript settlement', () => { ); }); }); + +describe('revision draft lifecycle over a prepared send', () => { + // The revision child can land in the catalog before reviseBeforeTurn + // resolves; the world below models the deferred handoff by letting + // openSessionInChat settle the active Session. + function createRevisionWorld(options: { composerText?: string } = {}) { + const activeIdRef: { current: string | undefined } = { current: SESSION_1 }; + let selectionRevision = 0; + let reviseCalls = 0; + const abandonedCopies: string[] = []; + const sessionDrafts = new Map(); + const clearedDrafts: string[] = []; + let composerText = options.composerText ?? ''; + const revisionDraftRef: { current: TurnRevisionDraft | null } = { current: null }; + const actions = createAppShellRevisionActions({ + uiLocale: 'en' as never, + activeIdRef, + captureSelection: () => { + const revision = selectionRevision; + return () => selectionRevision === revision; + }, + composerRef: { + current: { + getText: () => composerText, + setText: (text: string) => { composerText = text; }, + focus: () => {}, + setDraft: (sessionId: string, text: string) => { sessionDrafts.set(sessionId, text); }, + clearDraft: (sessionId: string) => { + clearedDrafts.push(sessionId); + sessionDrafts.delete(sessionId); + }, + }, + }, + messages: [userMessage('turn-1', 'original message')], + hasPendingAttachments: () => false, + openSessionInChat: (sessionId: string) => { + selectionRevision += 1; + activeIdRef.current = sessionId; + }, + refreshSessions: async () => [], + commitRevisionDraft: (draft: TurnRevisionDraft | null) => { + revisionDraftRef.current = draft; + }, + revisionDraftRef, + toastApi: { info: () => {}, error: () => {} }, + } as never); + const restoreWindow = installWindow({ + sessions: { + reviseBeforeTurn: async () => { + reviseCalls += 1; + return { id: SESSION_2 }; + }, + abandonSessionCopy: async (_sessionId: string, copyId: string) => { + abandonedCopies.push(copyId); + }, + }, + }); + return { + actions, + activeIdRef, + abandonedCopies, + sessionDrafts, + clearedDrafts, + revisionDraftRef, + restoreWindow, + get reviseCalls() { return reviseCalls; }, + get composerText() { return composerText; }, + }; + } + + it('retries a refused send inside the prepared child instead of opening another revision', async () => { + const world = createRevisionWorld(); + try { + world.actions.beginEditUserMessage('turn-1'); + assert.equal(await world.actions.prepareRevisionSend('edited text'), true); + // The refused send never reaches these actions: the draft keeps the + // child it already prepared, so the next send only has to not fork again. + assert.equal(await world.actions.prepareRevisionSend('edited text'), true); + assert.equal(world.reviseCalls, 1); + assert.equal(world.revisionDraftRef.current?.draftSessionId, SESSION_2); + assert.equal(world.sessionDrafts.get(SESSION_2), 'edited text'); + } finally { + world.restoreWindow(); + } + }); + + it('restores the complete pre-edit draft when a refused revision is cancelled', async () => { + const world = createRevisionWorld({ + composerText: 'previous unsent draft /skill:project-only', + }); + try { + world.actions.beginEditUserMessage('turn-1'); + assert.equal(await world.actions.prepareRevisionSend('edited with skill /skill:workspace-only'), true); + await world.actions.cancelRevisionDraft(); + assert.equal(world.revisionDraftRef.current, null); + assert.equal( + world.sessionDrafts.get(SESSION_1), + 'previous unsent draft /skill:project-only', + 'the pre-edit draft returns to its source Session, Skill token included', + ); + assert.deepEqual(world.clearedDrafts, [SESSION_2]); + assert.equal(world.abandonedCopies.length, 1); + assert.equal(world.activeIdRef.current, SESSION_1); + assert.equal(world.composerText, 'previous unsent draft /skill:project-only'); + } finally { + world.restoreWindow(); + } + }); +});