diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index e8583a686d..8bd366b228 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -70,12 +70,12 @@ "electron": "the perf budget is measured from CDP wheel input and the browser's own render skipping" }, "workhub-layout.spec.ts": { - "tests": 1, - "electron": "Coordination startup failure comes from the Host, and recovery needs a default model written back to it" + "tests": 2, + "electron": "WorkHub model configuration and attachment sending cross the Host boundary; native floating preserves macOS Dock visibility and pointer dragging verifies model-wheel scrolling and release snapping; a held-open Host Turn verifies immediate prompt visibility, durable transcript handoff and persistence after renderer reload" }, "workhub-reconstruction.spec.ts": { - "tests": 2, - "electron": "delegation linkage is rebuilt by the Host across navigation and across Sessions" + "tests": 1, + "electron": "the same WebContentsView and unsent draft survive reparenting between native main and floating windows, shared appearance changes and the explicit return-from-picture-in-picture button" }, "zh-tw-locale.spec.ts": { "tests": 1, diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 82f06d0436..a3d1b5e89e 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -86,19 +86,15 @@ export async function awaitSendReady(page: Page): Promise { }); } -/** - * Wait for the default Host's Coordination Session and the WorkHub projection - * to agree that the surface is ready. A mounted WorkHub main is not sufficient: - * it is also rendered while the Host reconnects and the projection reloads. - */ -export async function waitForWorkHubReady(page: Page, workCount: number): Promise { - await expect - .poll(async () => { - const snapshot = await page.evaluate(() => window.maka.runtimeHostProfiles.getSnapshot()); - return snapshot.entries.find(({ isDefault }) => isDefault)?.readiness; - }) - .toBe('ready'); - await expect(page.getByText(`${workCount} 项工作`, { exact: true })).toBeVisible(); +/** The persistent WorkHub WebContentsView is a distinct renderer, not part of the main DOM. */ +export async function getWorkHubPage(app: ElectronApplication): Promise { + let view: Page | undefined; + await expect.poll(() => { + view = app.context().pages().find((candidate) => new URL(candidate.url()).searchParams.get('surface') === 'workhub'); + return Boolean(view); + }).toBe(true); + await expect(view!.locator('.workHubLive .maka-composer-editor')).toBeVisible(); + return view!; } /** diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts index 059b43d2e6..8e42cb581e 100644 --- a/apps/desktop/e2e/workhub-layout.spec.ts +++ b/apps/desktop/e2e/workhub-layout.spec.ts @@ -17,28 +17,291 @@ * under the License. */ -import { expect, test } from './fixtures'; +import { FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend'; +import { awaitSendReady, COMPOSER_INPUT, expect, test, getWorkHubPage } from './fixtures'; -test('WorkHub explains Coordination startup failure and recovers after a default model is set', async ({ - window: page, -}) => { +test('WorkHub uses its coordination model and shared attachment composer', async ({ sessionLocalWindow: { page, app } }, testInfo) => { await page.evaluate(async () => { - await window.maka.connections.setDefaultModel(null); - await window.maka.settings.updateClient({ workHub: { enabled: true } }); + const { connections } = await window.maka.connections.getSnapshot(); + const connection = connections.find((entry) => entry.slug === 'e2e')!; + const ids = ['claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001', 'claude-opus-4-5-20251101']; + await window.maka.connections.update({ connectionId: connection.connectionId, slug: connection.slug }, { enabledModelIds: ids, models: ids.map((id) => ({ id })) }); }); - - const failure = page.getByRole('alert'); - await expect(failure).toContainText('WorkHub 暂时无法启动'); - await expect(failure).toContainText('请检查当前 Runtime Host 的默认模型配置'); - + await page.locator(COMPOSER_INPUT).fill('WorkHub navigation regression'); + await awaitSendReady(page); + await page.locator(COMPOSER_INPUT).press('Enter'); + await expect(page.getByText('Fake backend received: WorkHub navigation regression')).toBeVisible(); await page.evaluate(async () => { - await window.maka.connections.setDefaultModel({ - slug: 'e2e', - model: 'claude-sonnet-4-5-20250929', + for (let index = 0; index < 8; index++) await window.maka.sessions.create({ name: `Drag task ${index}` }); + }); + await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: true } })); + const workhub = await getWorkHubPage(app); + const sessionId = await workhub.evaluate(() => window.maka.workHub.resolveCoordinationSession()); + await expect.poll(async () => workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId)).toBeTruthy(); + await expect(workhub.locator('.maka-composer-editor [contenteditable="true"]')).toBeVisible(); + await expect(workhub.getByRole('button', { name: /添加上下文|Add context/ })).toBeEnabled(); + await expect(workhub.locator('.workhub-composer-scope')).toHaveCount(0); + await expect(workhub.locator('.workHubLiveHeader')).toHaveCount(0); + const model = workhub.getByRole('button', { name: /切换当前任务模型|Switch.*model|Change.*model/i }); + await expect(model).toBeEnabled(); + const mainWindow = await app.browserWindow(page); + const originalBounds = await mainWindow.evaluate((window) => window.getBounds()); + for (const width of [1240, 1000, 1600]) { + const contentWidth = await mainWindow.evaluate((window, width) => { + window.setBounds({ width }); + return window.getContentSize()[0]; + }, width); + await expect.poll(() => page.evaluate(() => innerWidth)).toBe(contentWidth); + const dockWidth = await page.locator('.workHubDock').evaluate((element) => Math.round(element.getBoundingClientRect().width)); + await expect.poll(() => workhub.evaluate(() => innerWidth)).toBe(dockWidth); + const rail = workhub.locator('.workhub-anchor-rail'); + await expect.poll(() => rail.evaluate((element) => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual(180); + await expect.poll(() => rail.locator('.workhub-navigation-label').first().evaluate((element) => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual(140); + await expect.poll(() => workhub.locator('.workhub-conversation-shell').evaluate((element) => { + const conversation = element.getBoundingClientRect(); + return conversation.left >= 0 && conversation.right <= innerWidth + 1; + })).toBe(true); + } + await mainWindow.evaluate((window, bounds) => window.setBounds(bounds), originalBounds); + const anchors = workhub.locator('.workhub-anchors'); + const draftBeforeOverlays = 'Draft survives main-window overlays and dragging.'; + await workhub.locator(COMPOSER_INPUT).fill(draftBeforeOverlays); + const railBounds = await anchors.boundingBox(); + const dragStart = { x: railBounds!.x + railBounds!.width - 40, y: railBounds!.y + railBounds!.height / 2 }; + await workhub.mouse.move(dragStart.x, dragStart.y); + await workhub.mouse.down(); + await workhub.mouse.move(dragStart.x - 300, dragStart.y, { steps: 12 }); + await workhub.mouse.up(); + await expect.poll(() => anchors.evaluate((element) => element.scrollLeft)).toBeGreaterThan(250); + await expect(page.locator('.workHubDock')).toBeVisible(); + await expect(workhub.locator(COMPOSER_INPUT)).toHaveText(draftBeforeOverlays); + await workhub.mouse.move(dragStart.x - 300, dragStart.y); + await workhub.mouse.down(); + await workhub.mouse.move(dragStart.x, dragStart.y, { steps: 12 }); + await workhub.mouse.up(); + await expect.poll(() => anchors.evaluate((element) => element.scrollLeft)).toBeLessThan(5); + const expandSidebar = page.getByRole('button', { name: '展开侧边栏', exact: true }); + if (await expandSidebar.isVisible()) await expandSidebar.click(); + const nativeWorkHubVisible = () => mainWindow.evaluate((window) => window.contentView.children.some((child) => 'webContents' in child && (child as Electron.WebContentsView).webContents.getURL().includes('surface=workhub') && child.getVisible())); + const actions = page.getByRole('button', { name: /Drag task 0.*任务操作$/ }); + await page.getByRole('button', { name: 'Drag task 0', exact: true }).hover(); + await actions.click(); + await expect(page.getByRole('menuitem', { name: '重命名', exact: true })).toBeVisible(); + await expect.poll(nativeWorkHubVisible).toBe(false); + await expect(page.locator('.workHubDockBackdrop')).toBeVisible(); + await page.getByRole('menuitem', { name: '重命名', exact: true }).click(); + await expect(page.getByRole('textbox', { name: '重命名任务' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect.poll(nativeWorkHubVisible).toBe(true); + await page.getByRole('button', { name: '搜索任务', exact: true }).click(); + await expect(page.locator('[data-maka-contract="search-modal"]')).toBeVisible(); + await expect.poll(nativeWorkHubVisible).toBe(false); + await page.keyboard.press('Escape'); + await expect.poll(nativeWorkHubVisible).toBe(true); + await expect(workhub.locator(COMPOSER_INPUT)).toHaveText(draftBeforeOverlays); + await expect(page.locator('.workHubDockBackdrop')).toHaveCount(0); + await anchors.locator('.workhub-navigation-item').first().click(); + await expect(page.locator('.workHubDock')).toBeHidden(); + await page.getByRole('button', { name: 'WorkHub', exact: true }).click(); + await expect(page.locator('.workHubDock')).toBeVisible(); + await expect(workhub.locator(COMPOSER_INPUT)).toHaveText(draftBeforeOverlays); + const configured = await workhub.evaluate(async (id) => { + const session = await window.maka.workHub.getSession(id); + return window.maka.workHub.configureModel(id, { + expectedRevision: session.revision, + modelTarget: { kind: 'explicit', connectionId: session.llmConnectionId!, connectionSlug: session.llmConnectionSlug, model: session.model }, }); + }, sessionId); + expect(configured.kind).toBe('committed'); + await workhub.locator('.maka-composer-editor [contenteditable="true"]').fill('WorkHub composer sends through its own coordination model.'); + await expect(workhub.getByRole('button', { name: /发送|Send/, exact: true })).toBeEnabled(); + await workhub.getByRole('button', { name: /发送|Send/, exact: true }).click(); + await expect(workhub.locator('[data-message-role="user"], article').filter({ hasText: 'WorkHub composer sends through its own coordination model.' }).first()).toBeVisible(); + await expect(workhub.locator('.maka-composer').getByRole('button', { name: /^(停止|Stop)$/ })).toHaveCount(0); + await workhub.reload(); + await expect(workhub.locator('article').filter({ hasText: 'WorkHub composer sends through its own coordination model.' }).first()).toBeVisible(); + await workhub.locator('[data-chat-scroll-container]').evaluate((element) => { element.scrollTop = element.scrollHeight; }); + await expect(workhub.locator('.astryx-chat-layout-scroll-button > div')).toHaveCSS('opacity', '0'); + // The macOS hidden-test launch starts without a Dock icon. Establish normal + // application visibility before checking the floating-window transition. + await app.evaluate(async ({ app }) => { if (process.platform === 'darwin') await app.dock!.show(); }); + await workhub.getByRole('button', { name: /浮出工作台|Float WorkHub/ }).click(); + await expect(workhub.locator('.workHubLive')).toHaveAttribute('data-placement', 'floating'); + expect(await app.evaluate(({ app }) => process.platform !== 'darwin' || app.dock!.isVisible())).toBe(true); + const editor = workhub.locator('.maka-composer-editor [contenteditable="true"]'); + await editor.fill('Keep this draft while folding the conversation.'); + const expandedHeight = await workhub.evaluate(() => window.innerHeight); + const floatingBottom = () => app.evaluate(({ BrowserWindow }) => { + const bounds = BrowserWindow.getAllWindows().find((window) => window.getTitle() === 'WorkHub')!.getBounds(); + return bounds.y + bounds.height; }); + const anchoredBottom = await floatingBottom(); + const scrollTop = await workhub.locator('[data-chat-scroll-container]').evaluate((element) => element.scrollTop); + const close = await workhub.getByRole('button', { name: /^(隐藏|Hide)$/ }).boundingBox(); + const input = await editor.boundingBox(); + expect(close!.y).toBeLessThan(input!.y); + await workhub.getByRole('button', { name: /收起对话|Collapse conversation/ }).click(); + await expect(workhub.locator('.workHubHistory')).toBeHidden(); + await expect(workhub.getByRole('button', { name: /滚动到底部|Scroll to bottom/ })).toHaveCount(0); + await expect.poll(() => workhub.evaluate(() => window.innerHeight)).toBeLessThan(expandedHeight / 2); + await expect(editor).toBeVisible(); + await expect(editor).toHaveText('Keep this draft while folding the conversation.'); + await expect(workhub.getByRole('button', { name: /^(隐藏|Hide)$/ })).toHaveCount(0); + await expect.poll(() => workhub.evaluate(() => innerHeight === Math.ceil(document.querySelector('.workHubComposerSurface')!.getBoundingClientRect().height))).toBe(true); + await expect.poll(floatingBottom).toBe(anchoredBottom); + const compactHeight = await workhub.evaluate(() => innerHeight); + await model.click(); + const wheel = workhub.getByRole('listbox'); + await expect(wheel).toBeVisible(); + await expect(wheel.getByRole('option')).toHaveCount(3); + await expect(workhub.locator('.workHubHistory')).toBeHidden(); + await expect.poll(() => workhub.evaluate(() => innerHeight)).toBeGreaterThan(compactHeight); + await expect.poll(floatingBottom).toBe(anchoredBottom); + const modelBeforeBrowsing = await workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId); + const selectedBeforeBrowsing = await wheel.getByRole('option', { selected: true }).getAttribute('id'); + await wheel.hover(); + await workhub.mouse.wheel(0, 30); + await expect.poll(() => wheel.evaluate((element) => { + const selected = document.getElementById(element.getAttribute('aria-activedescendant')!)!.getBoundingClientRect(); + const viewport = element.getBoundingClientRect(); + return Math.abs((selected.top + selected.bottom - viewport.top - viewport.bottom) / 2); + })).toBeLessThanOrEqual(1); + await expect(wheel.getByRole('option', { selected: true })).toHaveAttribute('id', selectedBeforeBrowsing!); + expect(await workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId)).toBe(modelBeforeBrowsing); + await wheel.press('Escape'); + await model.click(); + await expect(wheel.getByRole('option')).toHaveCount(3); + await expect.poll(() => workhub.evaluate(() => innerHeight === Math.ceil(document.querySelector('.workHubComposerSurface')!.getBoundingClientRect().height))).toBe(true); + const dragInitialTop = await wheel.evaluate((element) => element.scrollTop); + const dragDistance = dragInitialTop > 0 ? 32 : -32; + const wheelBounds = (await wheel.boundingBox())!; + const dragX = wheelBounds.x + wheelBounds.width / 2; + const dragY = wheelBounds.y + wheelBounds.height / 2; + await workhub.mouse.move(dragX, dragY); + await workhub.mouse.down(); + await workhub.mouse.move(dragX, dragY + dragDistance, { steps: 8 }); + await expect.poll(() => wheel.evaluate((element) => element.scrollTop)).toBe(dragInitialTop - dragDistance); + await workhub.mouse.up(); + await expect(wheel).toBeVisible(); + await expect.poll(() => wheel.evaluate((element) => element.scrollTop)).toBe(Math.round((dragInitialTop - dragDistance) / 44) * 44); + expect(await workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId)).toBe(modelBeforeBrowsing); + await wheel.press('ArrowDown'); + await wheel.press('Escape'); + await expect(wheel).toHaveCount(0); + await expect(model).toBeFocused(); + await model.click(); + const options = wheel.getByRole('option'); + await wheel.press(await options.first().getAttribute('aria-selected') === 'true' ? 'End' : 'Home'); + await expect(wheel.locator('[data-active="true"]')).toHaveAttribute('aria-selected', 'false'); + const previewLabel = await wheel.locator('[data-active="true"] .maka-model-wheel-label').innerText(); + const appearance = await page.evaluate(async () => (await window.maka.settings.getClient()).appearance); + for (const theme of ['light', 'dark'] as const) { + await page.evaluate((theme) => window.maka.settings.updateClient({ appearance: { theme } }), theme); + await expect.poll(() => workhub.evaluate(() => document.documentElement.classList.contains('dark'))).toBe(theme === 'dark'); + await expect(wheel).toBeFocused(); + await expect(wheel).toHaveCSS('outline-style', 'solid'); + await workhub.locator('.workHubComposerSurface').screenshot({ path: testInfo.outputPath(`model-wheel-${theme}.png`), animations: 'disabled' }); + } + await page.evaluate((appearance) => window.maka.settings.updateClient({ appearance }), appearance); + await wheel.press('Enter'); + await expect(wheel).toHaveCount(0); + await expect(model).toBeFocused(); + await expect.poll(() => workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).model, sessionId)).not.toBe(modelBeforeBrowsing); + await model.click(); + await expect(wheel.getByRole('option', { selected: true })).toContainText(previewLabel); + await wheel.press('Escape'); + await expect.poll(() => workhub.evaluate(() => innerHeight)).toBe(compactHeight); + await expect(editor).toHaveText('Keep this draft while folding the conversation.'); + const longDraft = Array.from({ length: 30 }, (_, index) => `第 ${index + 1} 行:长输入应当只在编辑区内滚动。`).join('\n'); + await editor.fill(longDraft); + await expect.poll(() => editor.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true); + await expect.poll(() => editor.evaluate((element) => element.clientHeight)).toBeLessThanOrEqual(132); + await expect(workhub.locator('[data-chat-scroll-container]')).toHaveCSS('overflow-y', 'hidden'); + await expect.poll(floatingBottom).toBe(anchoredBottom); + const longInputHeight = await workhub.evaluate(() => innerHeight); + await editor.hover(); + await workhub.mouse.wheel(0, 1000); + await expect.poll(() => editor.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); + await expect(workhub.getByRole('button', { name: /发送|Send/, exact: true })).toBeVisible(); + expect(await workhub.evaluate(() => innerHeight)).toBe(longInputHeight); + await editor.fill('Keep this draft while folding the conversation.'); + await expect.poll(() => workhub.evaluate(() => innerHeight)).toBe(compactHeight); + await workhub.getByRole('button', { name: /展开对话|Expand conversation/ }).click(); + await expect(workhub.locator('.workHubHistory')).toBeVisible(); + await expect.poll(() => workhub.evaluate(() => window.innerHeight)).toBe(expandedHeight); + await expect.poll(floatingBottom).toBe(anchoredBottom); + await expect.poll(() => workhub.locator('[data-chat-scroll-container]').evaluate((element) => element.scrollTop)).toBe(scrollTop); + await expect(editor).toHaveText('Keep this draft while folding the conversation.'); + await expect(model).toHaveAttribute('aria-haspopup', 'menu'); +}); - await expect(page.getByRole('region', { name: 'WorkHub' })).toBeVisible(); - await expect(page.locator('.workhub-empty')).toContainText('从这里继续所有工作'); - await expect(page.locator('.workhub-surface .maka-composer-editor')).toBeVisible(); + +test('WorkHub keeps the submitted prompt visible while its agent is still running', async ({ sessionLocalWindow: { page, app } }, testInfo) => { + await page.locator(COMPOSER_INPUT).fill('Initialize WorkHub model'); + await awaitSendReady(page); + await page.locator(COMPOSER_INPUT).press('Enter'); + await expect(page.getByText('Fake backend received: Initialize WorkHub model')).toBeVisible(); + await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: true } })); + let workhub = await getWorkHubPage(app); + await page.getByRole('button', { name: '展开侧边栏', exact: true }).click(); + await page.getByRole('button', { name: 'WorkHub', exact: true }).click(); + await workhub.getByRole('button', { name: /浮出工作台|Float WorkHub/ }).click(); + await expect(workhub.locator('.workHubLive')).toHaveAttribute('data-conversation-expanded', 'false'); + await workhub.locator(COMPOSER_INPUT).fill(FAKE_HOLD_OPEN_PROMPT); + await workhub.getByRole('button', { name: /发送|Send/, exact: true }).click(); + let prompt = workhub.locator('.maka-user-message').filter({ hasText: FAKE_HOLD_OPEN_PROMPT }); + await expect(workhub.locator('.workHubLive')).toHaveAttribute('data-conversation-expanded', 'true'); + await expect(prompt).toHaveCount(1); + await expect(prompt).toBeInViewport(); + await expect(workhub.locator('.maka-bubble-streaming')).toContainText('Fake backend waiting'); + let stop = workhub.locator('.maka-composer').getByRole('button', { name: /^(停止|Stop)$/ }); + await expect(stop).toBeVisible(); + await expect(prompt).toBeInViewport(); + const coordinationId = await workhub.evaluate(() => window.maka.workHub.resolveCoordinationSession()); + await workhub.getByRole('button', { name: /^(Return to Maka|收回 Maka)$/ }).click(); + const dockBounds = await page.locator('.workHubDock').boundingBox(); + await app.evaluate(({ webContents }) => { + webContents.getAllWebContents().find((contents) => contents.getURL().includes('surface=workhub'))!.forcefullyCrashRenderer(); + }); + await expect.poll(() => workhub.isClosed()).toBe(true); + await expect.poll(() => page.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'docked', rendererCrashed: true }); + await page.getByRole('button', { name: 'WorkHub', exact: true }).click(); + const retry = page.locator('.workHubDock').getByRole('button', { name: /^(Retry|重试)$/ }); + await expect(retry).toBeVisible(); + expect(await page.locator('.workHubDock').boundingBox()).toEqual(dockBounds); + await page.locator('.workHubDock').screenshot({ path: testInfo.outputPath('workhub-docked-retry.png') }); + await retry.click(); + workhub = await getWorkHubPage(app); + expect(await workhub.evaluate(() => window.maka.workHub.resolveCoordinationSession())).toBe(coordinationId); + prompt = workhub.locator('.maka-user-message').filter({ hasText: FAKE_HOLD_OPEN_PROMPT }); + stop = workhub.locator('.maka-composer').getByRole('button', { name: /^(停止|Stop)$/ }); + await expect(prompt).toHaveCount(1); + await expect(workhub.locator('.maka-bubble-streaming')).toContainText('Fake backend waiting'); + await expect(stop).toBeVisible(); + await stop.click(); + await expect(stop).toHaveCount(0); + await expect(workhub.locator('[data-transient-message-id]')).toHaveCount(0); + await expect(prompt).toHaveCount(1); + await expect(prompt).toBeInViewport(); + await workhub.getByRole('button', { name: /浮出工作台|Float WorkHub/ }).click(); + await workhub.getByRole('button', { name: /收起对话|Collapse conversation/ }).click(); + await expect(workhub.locator('.workHubHistory')).toBeHidden(); + await workhub.locator(COMPOSER_INPUT).fill(FAKE_HOLD_OPEN_PROMPT); + await workhub.getByRole('button', { name: /发送|Send/, exact: true }).click(); + await expect(workhub.locator('.workHubLive')).toHaveAttribute('data-conversation-expanded', 'true'); + await expect(prompt).toHaveCount(2); + await expect(prompt.last()).toBeInViewport(); + await stop.click(); + await expect(stop).toHaveCount(0); + await expect(workhub.locator('[data-transient-message-id]')).toHaveCount(0); + await expect(prompt).toHaveCount(2); + await app.evaluate(({ webContents }) => { + webContents.getAllWebContents().find((contents) => contents.getURL().includes('surface=workhub'))!.forcefullyCrashRenderer(); + }); + await expect.poll(() => workhub.isClosed()).toBe(true); + await page.evaluate(() => window.maka.workHubPresentation.detach()); + workhub = await getWorkHubPage(app); + await workhub.locator(COMPOSER_INPUT).fill('Reply after renderer recovery'); + await workhub.getByRole('button', { name: /发送|Send/, exact: true }).click(); + await expect(workhub.getByText('Fake backend received: Reply after renderer recovery')).toBeVisible(); }); diff --git a/apps/desktop/e2e/workhub-reconstruction.spec.ts b/apps/desktop/e2e/workhub-reconstruction.spec.ts index 3acf19c471..91816718ce 100644 --- a/apps/desktop/e2e/workhub-reconstruction.spec.ts +++ b/apps/desktop/e2e/workhub-reconstruction.spec.ts @@ -17,134 +17,77 @@ * under the License. */ -import { - COMPOSER_INPUT, - ensureSidebarExpanded, - expect, - test, - waitForWorkHubReady, -} from './fixtures'; +import { expect, test, getWorkHubPage } from './fixtures'; -test('WorkHub rebuilds delegated execution feedback after navigating away and back', async ({ - window: page, -}) => { - const initialPrompt = '检查支付回调重复投递时的幂等性'; - const composer = page.locator(COMPOSER_INPUT); - await composer.fill(initialPrompt); - await composer.press('Enter'); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { - timeout: 20_000, +test('WorkHub moves the same renderer and draft between the main window and floating window', async ({ sessionLocalWindow: { page, app } }, testInfo) => { + await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: true } })); + const workhub = await getWorkHubPage(app); + const appearance = await page.evaluate(async () => (await window.maka.settings.getClient()).appearance); + await page.evaluate(() => window.maka.settings.updateClient({ appearance: { theme: 'dark', palette: 'nord', uiFontSize: 18 } })); + const readAppearance = () => ({ + dark: document.documentElement.classList.contains('dark'), + palette: document.documentElement.dataset.makaTheme, + fontSize: document.documentElement.style.fontSize, }); - - const sessionName = await page.evaluate(async () => - (await window.maka.sessions.list())[0]?.name, - ); - expect(sessionName).toBeTruthy(); - await page.evaluate(async () => { - await window.maka.settings.updateClient({ workHub: { enabled: true } }); - }); - await expect(page.getByRole('region', { name: 'WorkHub' })).toBeVisible(); - // The conversation is the Coordination Session transcript. An ordinary - // Session is a routing target and a status row, never a turn in WorkHub. - await waitForWorkHubReady(page, 1); - await expect(page.locator('.workhub-turn')).toHaveCount(0); - await expect(page.locator('.workhub-empty h2')).toHaveText('从这里继续所有工作'); - - const routedPrompt = '继续这个工作,补充重复投递测试点。'; - const workHubComposer = page.locator( - '.workhub-surface .maka-composer-editor [contenteditable="true"]', - ); - await workHubComposer.fill(routedPrompt); - await workHubComposer.press('Enter'); - const routedTurn = page.locator('.workhub-turn', { hasText: routedPrompt }); - await expect(routedTurn.locator('.workhub-submitted')).toBeVisible(); - await expect(routedTurn.locator('.workhub-message-identity')).toContainText(sessionName!); - const workIdentity = await routedTurn.getAttribute('data-work-session-id'); - expect(workIdentity).toBeTruthy(); - const identityColor = await routedTurn.evaluate((element) => - getComputedStyle(element).getPropertyValue('--workhub-work-color'), - ); - expect(identityColor).toContain('oklch'); - - await routedTurn.locator('.workhub-submitted > button').click(); - await expect(page.getByRole('region', { name: 'WorkHub' })).toBeHidden(); - - await ensureSidebarExpanded(page); - await page.getByRole('button', { name: 'WorkHub', exact: true }).click(); - await waitForWorkHubReady(page, 1); - await expect(page.getByRole('region', { name: 'WorkHub' })).toBeVisible(); - await expect( - page.locator('.workhub-projected-turn .workhub-user-bubble > p', { - hasText: routedPrompt, - }), - ).toBeVisible(); - await expect( - page.locator('.workhub-projected-turn', { hasText: routedPrompt }) - .locator('.workhub-submitted-state'), - ).toHaveText('关联有效 · 已完成'); - await expect(routedTurn).toHaveAttribute('data-work-session-id', workIdentity!); - expect(await routedTurn.evaluate((element) => - getComputedStyle(element).getPropertyValue('--workhub-work-color'), - )).toBe(identityColor); - -}); - -test('WorkHub replaces the exact linked delegation across Sessions', async ({ - window: page, -}) => { - const sourceSessionName = '检查支付回调重复投递时的幂等性'; - const composer = page.locator(COMPOSER_INPUT); - await composer.fill(sourceSessionName); - await composer.press('Enter'); - await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { - timeout: 20_000, - }); - await page.evaluate(async (name) => { - const sourceSession = (await window.maka.sessions.list())[0]; - if (!sourceSession) throw new Error('Source Session was not found'); - await window.maka.sessions.rename(sourceSession.id, name); - }, sourceSessionName); - await page.evaluate(async () => { - await window.maka.settings.updateClient({ workHub: { enabled: true } }); + await expect.poll(() => workhub.evaluate(readAppearance)).toMatchObject({ dark: true, palette: 'nord' }); + await expect.poll(() => page.evaluate(readAppearance)).toEqual(await workhub.evaluate(readAppearance)); + await page.evaluate((appearance) => window.maka.settings.updateClient({ appearance }), appearance); + const editor = workhub.locator('.maka-composer-editor [contenteditable="true"]'); + await editor.fill('Keep this unsent WorkHub draft'); + const marker = await editor.evaluate((element) => { + const value = crypto.randomUUID(); + element.setAttribute('data-test-instance', value); + return value; }); - await expect(page.getByRole('region', { name: 'WorkHub' })).toBeVisible(); - await page.evaluate(async () => { - await window.maka.sessions.create({ name: '登录稳定性' }); + const webContentsId = await workhub.evaluate(() => window.maka.workHub.resolveCoordinationSession()); + await workhub.emulateMedia({ reducedMotion: 'no-preference' }); + await editor.evaluate((element) => { + element.addEventListener('input', () => { + if (document.querySelector('.workHubRevealMark')?.getAnimations().some((animation) => animation.playState === 'running')) { + element.setAttribute('data-input-during-reveal', 'true'); + } + }); }); - await waitForWorkHubReady(page, 2); - - const workHubComposer = page.locator( - '.workhub-surface .maka-composer-editor [contenteditable="true"]', - ); - await workHubComposer.fill('继续这个工作,补充重复投递测试点。'); - await workHubComposer.press('Enter'); - const continuedTurn = page.locator('.workhub-turn', { - hasText: '继续这个工作,补充重复投递测试点。', - }); - await expect( - continuedTurn.locator('.workhub-submitted-session strong'), - ).toHaveText(sourceSessionName); - await waitForWorkHubReady(page, 2); - - await workHubComposer.fill('不是这个,换成登录稳定性,补充刷新令牌失败判定。'); - await expect( - page.locator('.workhub-surface').getByRole('button', { name: '发送' }), - ).toBeEnabled(); - await workHubComposer.press('Enter'); + await workhub.getByRole('button', { name: /^(Float WorkHub|浮出工作台)$/ }).click(); + await workhub.keyboard.type(' — typed during opening'); + await expect(editor).toHaveAttribute('data-input-during-reveal', 'true'); + await expect(page.locator('.workHubDockPlaceholder')).toBeVisible(); + await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'floating', floatingVisible: true }); + await expect(editor).toHaveText('Keep this unsent WorkHub draft — typed during opening'); + await expect(editor).toHaveAttribute('data-test-instance', marker); - const correctionTurn = page.locator('.workhub-turn', { - hasText: '不是这个,换成登录稳定性,补充刷新令牌失败判定。', + await expect(workhub.locator('.workHubLive')).toHaveAttribute('data-conversation-expanded', 'false'); + const capabilities = await app.evaluate(({ BrowserWindow }) => { + const floating = BrowserWindow.getAllWindows().find((window) => window.getTitle() === 'WorkHub')!; + return { maximizable: floating.isMaximizable(), fullscreenable: floating.isFullScreenable() }; }); - await expect( - correctionTurn.locator('.workhub-submitted-session strong'), - ).toHaveText('登录稳定性'); - await expect(correctionTurn.locator('.workhub-error')).toHaveCount(0); - await expect( - continuedTurn.locator('.workhub-submitted-state'), - ).toHaveText('已被更正'); - await expect( - correctionTurn.locator('.workhub-submitted-state'), - ).toContainText( - /^关联有效 · (?:已接收|进行中|等待你|已完成|失败|已中止|正在恢复)$/u, - ); + expect(capabilities.fullscreenable).toBe(false); + if (process.platform !== 'linux') expect(capabilities.maximizable).toBe(false); + await expect(workhub.locator('.workHubHistory')).toBeHidden(); + await expect.poll(() => workhub.evaluate(() => Math.abs(innerHeight - document.querySelector('.workHubComposerSurface')!.getBoundingClientRect().height))).toBeLessThanOrEqual(1); + await workhub.getByRole('button', { name: /展开对话|Expand conversation/ }).click(); + await expect(workhub.locator('.workHubHistory')).toBeVisible(); + await workhub.getByRole('button', { name: /^(Hide|隐藏|隱藏)$/ }).click(); + await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'floating', floatingVisible: false }); + await page.getByRole('button', { name: /^(Bring WorkHub back|收回工作台)$/ }).click(); + await expect(page.locator('.workHubDockPlaceholder')).toBeHidden(); + await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'docked' }); + await expect(editor).toHaveText('Keep this unsent WorkHub draft — typed during opening'); + await expect(editor).toHaveAttribute('data-test-instance', marker); + expect(await workhub.evaluate(() => window.maka.workHub.resolveCoordinationSession())).toBe(webContentsId); + expect(app.context().pages().filter((candidate) => candidate.url().includes('surface=workhub'))).toHaveLength(1); + await workhub.getByRole('button', { name: /^(Float WorkHub|浮出工作台)$/ }).click(); + await expect(workhub.locator('.workHubLive')).toHaveAttribute('data-conversation-expanded', 'false'); + await expect(workhub.getByRole('button', { name: /^(Return to Maka|收回 Maka)$/ })).toHaveCount(0); + await workhub.getByRole('button', { name: /展开对话|Expand conversation/ }).click(); + await workhub.locator('.workHubWindowControls').screenshot({ path: testInfo.outputPath('workhub-return-from-pip.png') }); + await workhub.getByRole('button', { name: /^(Return to Maka|收回 Maka)$/ }).click(); + await expect.poll(() => workhub.evaluate(() => window.maka.workHubPresentation.getSnapshot())).toMatchObject({ placement: 'docked' }); + await expect(editor).toHaveAttribute('data-test-instance', marker); + await expect(editor).toHaveText('Keep this unsent WorkHub draft — typed during opening'); + await workhub.getByRole('button', { name: /^(Float WorkHub|浮出工作台)$/ }).click(); + await expect(workhub.getByRole('button', { name: '发送', exact: true })).toBeEnabled(); + await workhub.getByRole('button', { name: '发送', exact: true }).click(); + await expect(workhub.locator('.workHubLive')).toHaveAttribute('data-conversation-expanded', 'true'); + await expect(workhub.locator('article').filter({ hasText: 'Keep this unsent WorkHub draft' }).first()).toBeVisible(); }); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 4712d5e0c1..f152858a08 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -26,7 +26,6 @@ "src/renderer/astryx-theme-mode.ts", "src/renderer/astryx-theme/maka.js", "src/renderer/astryx-theme/makaTheme.ts", - "src/renderer/astryx-theme/type-scale.ts", "src/renderer/attachment-preflight.ts", "src/renderer/bootstrap-selection-lease.ts", "src/renderer/browser-storage.ts", @@ -51,7 +50,6 @@ "src/renderer/derive-turn-lineage-badges.ts", "src/renderer/desktop-execution-boundary-surface.ts", "src/renderer/desktop-slash-command.ts", - "src/renderer/desktop-transcript-range-store.ts", "src/renderer/error-boundary.tsx", "src/renderer/follow-up-submit-routing.ts", "src/renderer/interrupted-resume.ts", @@ -245,13 +243,6 @@ "src/renderer/use-turn-action-registry.ts", "src/renderer/use-work-board.ts", "src/renderer/work-board-panel.tsx", - "src/renderer/workhub-controller.ts", - "src/renderer/workhub-coordination-host-scope.ts", - "src/renderer/workhub-coordination-lifecycle.ts", - "src/renderer/workhub-coordination-port.ts", - "src/renderer/workhub-send-lease.ts", - "src/renderer/workhub-session-port.ts", - "src/renderer/workhub-surface.tsx", "src/renderer/workspace-readiness-recovery.ts" ], "legacyGrowthDirectories": [ @@ -498,8 +489,8 @@ "../shared/runtime-host-identity.js": 1, "./app-shell-copy": 1, "./browser-storage": 1, - "./desktop-transcript-range-store.js": 1, "./locales/conversation-copy.js": 1, + "./platform/desktop/desktop-transcript-range-store.js": 1, "./session-event-health": 1, "./shell-run-update-state.js": 1, "./theme": 1, @@ -719,18 +710,15 @@ "nonTriviaTokens": 1395 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 77, + "importDeclarations": 71, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, - "window.maka.connections.subscribeEvents": 1, "window.maka.diagnostics.copyPreviousMainProcessInterruption": 1, "window.maka.diagnostics.copyReport": 1, "window.maka.diagnostics.takePreviousMainProcessInterruption": 1, "window.maka.notifications.runEnded": 1, "window.maka.onboarding.setMilestone": 1, - "window.maka.runtimeHostProfiles.subscribeChanges": 1, - "window.maka.sessions": 1, "window.maka.sessions.abandonPlanProposal": 1, "window.maka.sessions.compact": 1, "window.maka.sessions.getPlanState": 1, @@ -743,12 +731,7 @@ "window.maka.sessions.subscribeActiveInteractions": 1, "window.maka.sessions.updateQueueEntry": 1, "window.maka.settings.getClient": 1, - "window.maka.settings.subscribeClientChanged": 1, - "window.maka.transcripts": 2, - "window.maka.workHub.act": 1, - "window.maka.workHub.candidates": 1, - "window.maka.workHub.record": 1, - "window.maka.workHub.resolveCoordinationSession": 1 + "window.maka.settings.subscribeClientChanged": 1 }, "environmentCapabilities": { "document.querySelector": 1, @@ -771,13 +754,13 @@ "useAppShellTurnPresentation": 1, "useCommandPalette": 1, "useComposerAttachments": 1, - "useEffect": 8, + "useEffect": 7, "useKeyboardHelp": 1, "useLayoutEffect": 2, "useNewTaskChoice": 1, "useOnboardingSnapshot": 1, "usePlanModeState": 1, - "useRef": 20, + "useRef": 16, "useSessionCollaborationDialog": 1, "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, @@ -792,7 +775,7 @@ "useShellRunUpdates": 1, "useShellSearch": 1, "useStableActions": 6, - "useState": 16, + "useState": 14, "useSystemUiLocale": 1, "useTaskSubmissionReadiness": 1, "useToast": 1, @@ -839,6 +822,7 @@ "./features/session-settings": 1, "./features/task-entry": 1, "./features/workbar": 1, + "./features/workhub": 1, "./follow-up-submit-routing": 1, "./keyboard-help": 1, "./live-content-seed": 1, @@ -877,12 +861,6 @@ "./use-system-ui-locale": 1, "./use-task-submission-readiness": 1, "./use-turn-action-registry": 1, - "./workhub-controller.js": 1, - "./workhub-coordination-host-scope.js": 1, - "./workhub-coordination-lifecycle.js": 1, - "./workhub-coordination-port.js": 1, - "./workhub-session-port.js": 1, - "./workhub-surface.js": 1, "./workspace-readiness-recovery": 1, "@astryxdesign/core/AppShell": 1, "@astryxdesign/core/Button": 1, @@ -894,8 +872,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 116, - "nonTriviaTokens": 14338 + "importSpecifiers": 109, + "nonTriviaTokens": 13730 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, @@ -1085,15 +1063,6 @@ "actionFactories": [], "dependencyPaths": {} }, - "src/renderer/astryx-theme/type-scale.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, "src/renderer/attachment-preflight.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -1385,23 +1354,6 @@ "@maka/core/swarm-command": 1 } }, - "src/renderer/desktop-transcript-range-store.ts": { - "bridgePaths": {}, - "environmentCapabilities": { - "window.clearTimeout": 1, - "window.setTimeout": 1 - }, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "../shared/desktop-session-projection.js": 1, - "../shared/runtime-host-identity.js": 1, - "@maka/core/persisted-value": 1, - "@maka/core/session": 1 - } - }, "src/renderer/error-boundary.tsx": { "bridgePaths": { "window.maka.diagnostics": 3, @@ -2270,7 +2222,7 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./desktop-transcript-range-store.js": 1 + "./platform/desktop/desktop-transcript-range-store.js": 1 } }, "src/renderer/session-read-state.ts": { @@ -4118,13 +4070,7 @@ }, "environmentCapabilities": { "document.createElement": 1, - "document.documentElement": 9, - "document.documentElement.classList.contains": 1, - "document.documentElement.classList.toggle": 1, - "document.documentElement.removeAttribute": 1, - "document.documentElement.setAttribute": 1, - "document.documentElement.style.colorScheme": 1, - "document.documentElement.style.fontSize": 1, + "document.documentElement": 3, "document.querySelector": 1, "window.matchMedia": 1 }, @@ -4133,8 +4079,8 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./astryx-theme/type-scale.js": 1, "./browser-storage": 1, + "./platform/desktop/document-appearance.js": 1, "./titlebar-dim-color.js": 1, "@maka/core/settings": 1 } @@ -4581,106 +4527,6 @@ "react": 1 } }, - "src/renderer/workhub-controller.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./application/contracts/operation-diagnostics.js": 1, - "./features/workhub/index.js": 1 - } - }, - "src/renderer/workhub-coordination-host-scope.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "../shared/runtime-host-identity.js": 1 - } - }, - "src/renderer/workhub-coordination-lifecycle.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "../shared/runtime-host-identity.js": 1 - } - }, - "src/renderer/workhub-coordination-port.ts": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./desktop-transcript-range-store.js": 1, - "./workhub-controller.js": 1, - "@maka/core/session": 1 - } - }, - "src/renderer/workhub-send-lease.ts": { - "bridgePaths": {}, - "environmentCapabilities": { - "document": 1, - "window": 1, - "window.localStorage": 1 - }, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": {} - }, - "src/renderer/workhub-session-port.ts": { - "bridgePaths": {}, - "environmentCapabilities": { - "window.clearTimeout": 1, - "window.setTimeout": 1 - }, - "hookCalls": {}, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "../shared/runtime-host-identity.js": 1, - "./desktop-transcript-range-store.js": 1, - "./workhub-controller.js": 1, - "@maka/core/session": 1 - } - }, - "src/renderer/workhub-surface.tsx": { - "bridgePaths": {}, - "environmentCapabilities": {}, - "hookCalls": { - "useEffect": 2, - "useRef": 3, - "useState": 8 - }, - "lifecycleMethods": {}, - "unresolvedDependencies": 0, - "actionFactories": [], - "dependencyPaths": { - "./application/contracts/operation-diagnostics.js": 1, - "./features/workhub/index.js": 1, - "./locales/workhub-copy.js": 2, - "./workhub-coordination-port.js": 1, - "./workhub-send-lease.js": 1, - "@astryxdesign/core": 1, - "@astryxdesign/core/Button": 1, - "@maka/ui": 1, - "react": 1 - } - }, "src/renderer/workspace-readiness-recovery.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -4779,9 +4625,9 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "./app-shell": 1, "./astryx-theme-mode": 1, "./astryx-theme/maka": 1, + "./composition/legacy-desktop-region": 1, "@astryxdesign/core/theme": 1, "react": 1 }, diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index 047dd85bb4..a2ec8d90c2 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -2623,7 +2623,41 @@ function validateMainRendererLoader(desktopRoot, violations) { const [loaderFunction] = loaderFunctions; const [resolverFunction] = resolverFunctions; const ifStatements = loaderFunction ? nodesIn(loaderFunction.body).filter((node) => node.type === 'IfStatement') : []; - const [loadBranch] = ifStatements; + const hasWorkHubSurface = loaderFunction?.params.length === 3; + const loadBranch = ifStatements[hasWorkHubSurface ? 1 : 0]; + const surfaceBranch = ifStatements[0]; + const surfaceStatements = surfaceBranch?.consequent?.body ?? []; + const surfaceUrl = surfaceStatements[0]?.declarations?.[0]; + const surfaceQuery = surfaceStatements[1]?.expression; + const surfaceParameter = loaderFunction?.params[2]; + const validWorkHubSurface = + !hasWorkHubSurface || ( + isIdentifier(surfaceParameter, 'surface') && + surfaceParameter.optional === true && + surfaceParameter.typeAnnotation?.typeAnnotation?.type === 'TSLiteralType' && + staticString(surfaceParameter.typeAnnotation.typeAnnotation.literal) === 'workhub' && + isIdentifier(surfaceBranch.test, 'surface') && + !surfaceBranch.alternate && + surfaceStatements.length === 4 && + surfaceStatements[0].kind === 'const' && + surfaceStatements[0].declarations.length === 1 && + isIdentifier(surfaceUrl?.id, 'url') && + surfaceUrl.init?.type === 'NewExpression' && + isIdentifier(surfaceUrl.init.callee, 'URL') && + surfaceUrl.init.arguments.length === 1 && + isNamedMember(surfaceUrl.init.arguments[0], 'rendererEntry', 'url') && + surfaceQuery?.type === 'CallExpression' && + isMemberExpression(surfaceQuery.callee) && + isNamedMember(surfaceQuery.callee.object, 'url', 'searchParams') && + memberPropertyName(surfaceQuery.callee) === 'set' && + surfaceQuery.arguments.length === 2 && + staticString(surfaceQuery.arguments[0]) === 'surface' && + isIdentifier(surfaceQuery.arguments[1], 'surface') && + isOnlyAwaitedMemberCall({ type: 'BlockStatement', body: [surfaceStatements[2]] }, 'mainWindow', 'loadURL', 'url', 'href') && + surfaceStatements[3].type === 'ReturnStatement' && + !surfaceStatements[3].argument && + !program.body.some((statement) => statementBindings(statement).includes('URL')) + ); const resolverReturns = resolverFunction ? nodesIn(resolverFunction.body).filter((node) => node.type === 'ReturnStatement') : []; @@ -2653,17 +2687,18 @@ function validateMainRendererLoader(desktopRoot, violations) { loaderFunctions.length === 1 && loaderFunction.async === true && JSON.stringify(loaderFunction.params.map((parameter) => parameter.type === 'Identifier' ? parameter.name : undefined)) === - JSON.stringify(['mainWindow', 'rendererEntry']) && - loaderFunction.body.body.length === 1 && + JSON.stringify(hasWorkHubSurface ? ['mainWindow', 'rendererEntry', 'surface'] : ['mainWindow', 'rendererEntry']) && + validWorkHubSurface && + loaderFunction.body.body.length === (hasWorkHubSurface ? 2 : 1) && entryPaths.length === 1 && isRendererEntryPathInitializer(entryPaths[0].init) && entryUrls.length === 1 && isRendererEntryUrlInitializer(entryUrls[0].init) && - loadCalls.length === 2 && + loadCalls.length === (hasWorkHubSurface ? 3 : 2) && loadCalls.filter((node) => isMemberCall(node, 'mainWindow', 'loadFile', 'rendererEntry', 'filePath')).length === 1 && loadCalls.filter((node) => isMemberCall(node, 'mainWindow', 'loadURL', 'rendererEntry', 'url')).length === 1 && - navigationTokens.length === 4 && - ifStatements.length === 1 && + navigationTokens.length === (hasWorkHubSurface ? 5 : 4) && + ifStatements.length === (hasWorkHubSurface ? 2 : 1) && isNamedMember(loadBranch.test, 'rendererEntry', 'useDevServer') && isOnlyAwaitedMemberCall(loadBranch.consequent, 'mainWindow', 'loadURL', 'rendererEntry', 'url') && isOnlyAwaitedMemberCall(loadBranch.alternate, 'mainWindow', 'loadFile', 'rendererEntry', 'filePath'); @@ -3074,6 +3109,8 @@ function isSanctionedDependencyTarget(desktopRoot, section, importerPath, depend } const MIGRATION_SWAP_ZONES = { + legacyAppShell: ['platform'], + legacyAppShellClosure: ['platform'], rootDebt: ['bootstrap', 'composition'], rootDebtClosure: ['application', 'bootstrap', 'composition', 'platform'], }; diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index db2c11d8cf..f3b0eba282 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -1191,6 +1191,24 @@ describe('renderer architecture checker fixtures', () => { ); }); + it('allows replacing a legacy dependency with a platform adapter without growing imports', async () => { + const target = 'src/renderer/platform/desktop/transcript.ts'; + await withDesktopFixture( + transitiveAppShellFiles( + `import { transcript } from './platform/desktop/transcript.js'; export const legacySessionHelper = transcript;`, + { [target]: 'export const transcript = 1;' }, + ), + (desktopRoot) => { + const current = generateArchitectureConfig(desktopRoot, transitiveAppShellSeedConfig()); + const base = structuredClone(current); + base.legacyAppShell.closure[TRANSITIVE_LEGACY_HELPER_PATH].dependencyPaths = { './legacy-transcript.js': 1 }; + assert.deepEqual(violationsFor(desktopRoot, current, base), []); + base.legacyAppShell.closure[TRANSITIVE_LEGACY_HELPER_PATH].dependencyPaths = {}; + assertHasViolation(violationsFor(desktopRoot, current, base), /new dependency debt/u); + }, + ); + }); + it('rejects a stale AppShell closure ledger when another legacy file becomes reachable', async () => { const newlyReachablePath = 'src/renderer/legacy-session-store.ts'; await withDesktopFixture( @@ -1417,6 +1435,30 @@ describe('renderer architecture checker fixtures', () => { ); }); + it('allows a WorkHub query on the pinned document, but rejects another surface or document', async () => { + for (const variant of ['workhub', 'arbitrary-surface', 'alternate-document']) { + const files = rendererEntryContractFiles(); + files['src/main/main-renderer-loader.ts'] = files['src/main/main-renderer-loader.ts'].replace( + 'rendererEntry: MainRendererEntry,\n ): Promise {', + `rendererEntry: MainRendererEntry, + surface?: '${variant === 'arbitrary-surface' ? 'other' : 'workhub'}', + ): Promise { + if (surface) { + const url = new URL(${variant === 'alternate-document' ? "'https://other.example'" : 'rendererEntry.url'}); + url.searchParams.set('surface', surface); + await mainWindow.loadURL(url.href); + return; + }`, + ); + await withDesktopFixture(files, (desktopRoot) => { + const config = generateArchitectureConfig(desktopRoot, rendererEntrySeedConfig()); + const violations = checkRendererArchitecture({ config, desktopRoot }); + if (variant === 'workhub') assert.deepEqual(violations, []); + else assertHasViolation(violations, /renderer loader must load only the pinned/u); + }); + } + }); + it('rejects replacing the pinned renderer module entry with an alternate source', async () => { await withDesktopFixture( rendererEntryContractFiles({ diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index d851594a3f..d8f56d9990 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -37,7 +37,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { LiveTurnProjection } from '@maka/ui'; -import type { DesktopTranscriptRangeController } from '../../renderer/desktop-transcript-range-store.js'; +import type { DesktopTranscriptRangeController } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { createAppShellChatActions } from '../../renderer/app-shell-chat-actions.js'; import { prepareTranscriptForSend } from '../../renderer/features/conversation/testing.js'; diff --git a/apps/desktop/src/main/__tests__/browser-tools.test.ts b/apps/desktop/src/main/__tests__/browser-tools.test.ts index d5e6736ea7..c60714527d 100644 --- a/apps/desktop/src/main/__tests__/browser-tools.test.ts +++ b/apps/desktop/src/main/__tests__/browser-tools.test.ts @@ -266,7 +266,7 @@ describe('browser tool execution', () => { }, releaseBrowserSession() {}, computerUseTools, - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }); assert.ok(provider.call); if (!provider.call) return; diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts index 0fb5b45c83..d73d8ad343 100644 --- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts @@ -119,6 +119,7 @@ async function mountRegion(): Promise<{ onboardingComposerHidden: false, activeInteraction: undefined, activeId, + contextUsageSessionId: activeId, newTaskDraftKey, newTaskSendPending, stopPendingBySession: {}, diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index a0284159f0..6c84432260 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -35,7 +35,7 @@ import { createRecoveringDesktopTranscriptRangeController, createDesktopTranscriptRangeController, DesktopTranscriptRangeStore, -} from '../../renderer/desktop-transcript-range-store.js'; +} from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { mergeSettledMessages } from '../../renderer/settled-message-merge.js'; import { readSettledMessages } from '../../renderer/session-message-settlement.js'; import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; diff --git a/apps/desktop/src/main/__tests__/font-size-type-scale.test.ts b/apps/desktop/src/main/__tests__/font-size-type-scale.test.ts index d515628525..cfabebbda6 100644 --- a/apps/desktop/src/main/__tests__/font-size-type-scale.test.ts +++ b/apps/desktop/src/main/__tests__/font-size-type-scale.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { DEFAULT_UI_FONT_SIZE } from '@maka/core/settings'; -import { TYPE_SCALE_BASE_PX } from '../../renderer/astryx-theme/type-scale.js'; +import { TYPE_SCALE_BASE_PX } from '../../renderer/platform/desktop/document-appearance.js'; import { makaTheme } from '../../renderer/astryx-theme/makaTheme.js'; // `DEFAULT_UI_FONT_SIZE` (in @maka/core, which cannot import renderer code) diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts index 022c5e6e77..463f68891e 100644 --- a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -60,7 +60,7 @@ test('retains process lifetime before a standalone startup dialog can close', () ); assert.match( windowAllClosed, - /process\.platform !== "darwin" && !isBrowserMessageBoxPresentationActive\(\) &&\s*!isDesktopStartupInProgress\(\)/u, + /process\.platform !== "darwin" && !windowsAppTray\.hasTray\(\) && !isBrowserMessageBoxPresentationActive\(\) &&\s*!isDesktopStartupInProgress\(\)/u, ); }); diff --git a/apps/desktop/src/main/__tests__/main-window-permission-policy.test.ts b/apps/desktop/src/main/__tests__/main-window-permission-policy.test.ts index df34f9b276..3616fe15c3 100644 --- a/apps/desktop/src/main/__tests__/main-window-permission-policy.test.ts +++ b/apps/desktop/src/main/__tests__/main-window-permission-policy.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { EventEmitter } from 'node:events'; import type { WebContents } from 'electron'; import { allowsMainWindowPermissionCheck, @@ -188,8 +189,8 @@ describe('main window Chromium permission policy', () => { requestHandler = handler; }, }; - const owner = { session } as unknown as WebContents; - const other = { session } as unknown as WebContents; + const owner = Object.assign(new EventEmitter(), { session }) as unknown as WebContents; + const other = Object.assign(new EventEmitter(), { session }) as unknown as WebContents; installMainWindowPermissionPolicy(owner, 'file:///Applications/Maka.app/index.html'); assert.ok(checkHandler); @@ -226,5 +227,14 @@ describe('main window Chromium permission policy', () => { requestingUrl: 'file:///Applications/Maka.app/index.html', }); assert.equal(clipboardGranted, true); + + // Registering the reparentable WorkHub view must preserve the main window's grant. + installMainWindowPermissionPolicy(other, 'file:///Applications/Maka.app/index.html'); + const details = { isMainFrame: true, requestingUrl: 'file:///Applications/Maka.app/index.html?surface=workhub' }; + assert.equal(checkHandler(owner, 'clipboard-sanitized-write', 'file://', details), true); + assert.equal(checkHandler(other, 'clipboard-sanitized-write', 'file://', details), true); + other.emit('destroyed'); + assert.equal(checkHandler(other, 'clipboard-sanitized-write', 'file://', details), false); + assert.equal(checkHandler(owner, 'clipboard-sanitized-write', 'file://', details), true); }); }); diff --git a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts index 19bb633282..7d14c551ed 100644 --- a/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-runtime-e2e.test.ts @@ -56,7 +56,7 @@ test('MCP tools stay bound to the connection generation that advertised them', a resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: [] as never, - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp_fixture', diff --git a/apps/desktop/src/main/__tests__/password-input.test.ts b/apps/desktop/src/main/__tests__/password-input.test.ts index 56d88a688e..a75e8fba20 100644 --- a/apps/desktop/src/main/__tests__/password-input.test.ts +++ b/apps/desktop/src/main/__tests__/password-input.test.ts @@ -26,11 +26,9 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { parseHTML } from "linkedom"; import { build } from "esbuild"; -import { - AstryxLocaleProvider, - LocaleProvider, - ToastProvider, -} from "@maka/ui"; +import type { WebContents } from "electron"; +import { WorkHubSurface } from "../workhub-surface.js"; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from "@maka/ui"; import type * as PasswordInputModule from "../../renderer/settings/password-input.js"; const REPO_ROOT = resolve(import.meta.dirname, "../../../../.."); @@ -41,8 +39,9 @@ const originalGlobals = { HTMLElement: globalThis.HTMLElement, Node: globalThis.Node, Event: globalThis.Event, - IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) - .IS_REACT_ACT_ENVIRONMENT, + IS_REACT_ACT_ENVIRONMENT: ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT, }; let mountedRoot: Root | undefined; @@ -69,13 +68,99 @@ test("mouse focus moving from the password draft to Eye does not commit and Eye assert.equal(input.value, "complete-secret"); }); +test("assistant observations exclude credentials and their controls before and after reveal", async (t) => { + const { document } = await renderPasswordInputs(); + t.mock.method(HTMLElement.prototype, "getBoundingClientRect", () => ({ + x: 10, + y: 10, + top: 10, + left: 10, + right: 210, + bottom: 50, + width: 200, + height: 40, + })); + const wc = { + executeJavaScript: async (script: string) => + new Function( + "document", + "getComputedStyle", + "innerHeight", + "innerWidth", + `return ${script}`, + )(document, () => ({ visibility: "visible", opacity: "1" }), 720, 1158), + } as unknown as WebContents; + const surface = new WorkHubSurface(); + const inputs = [...document.querySelectorAll("input")]; + const showButtons = [ + ...document.querySelectorAll( + 'button[aria-label="Show"]', + ), + ]; + for (const reveal of [false, true]) { + if (reveal) + await act(async () => { + for (const button of showButtons) button.click(); + }); + assert.ok( + inputs.every((input) => input.type === (reveal ? "text" : "password")), + ); + await surface.prepare(wc); + const nodes: { + nodeId: string; + backendDOMNodeId: number; + role: { value: string }; + name: { value: string }; + value: { value: string }; + }[] = []; + const dom = (element: Element): Parameters[0] => { + const id = nodes.length + 1; + nodes.push({ + nodeId: String(id), + backendDOMNodeId: id, + role: { value: element.tagName === "INPUT" ? "textbox" : "button" }, + name: { + value: + element.getAttribute("aria-label") ?? element.textContent ?? "", + }, + value: { value: (element as HTMLInputElement).value ?? "" }, + }); + return { + nodeName: element.tagName, + backendNodeId: id, + attributes: [...element.attributes].flatMap(({ name, value }) => [ + name, + value, + ]), + children: [...element.children].map(dom), + }; + }; + const root = { + nodeName: "HTML", + backendNodeId: 0, + children: [...document.children].map(dom), + }; + const observation = surface.filter(root, { nodes }); + assert.ok( + inputs.every((input) => !input.hasAttribute("data-maka-assistant-ref")), + ); + assert.equal(JSON.stringify(observation).includes("secret"), false); + assert.deepEqual( + surface.list().map(({ name }) => name), + ["outside"], + ); + } +}); + test("keyboard focus stays inside through Eye and commits once when Tab leaves the group", async () => { const harness = await renderPasswordInputs(); const input = harness.document.querySelector("input") as HTMLInputElement; const show = harness.document.querySelector( 'button[aria-label="Show"]', ) as HTMLButtonElement; - const outside = harness.document.querySelector("#outside") as HTMLButtonElement; + const outside = harness.document.querySelector( + "#outside", + ) as HTMLButtonElement; harness.focusExit(input, show); assert.equal(harness.exits, 0); @@ -177,7 +262,9 @@ async function renderPasswordInputs(): Promise<{ locale: "en", children: createElement(AstryxLocaleProvider, { children: createElement(ToastProvider, { - children: createElement("div", {}, + children: createElement( + "div", + {}, createElement(PasswordInput, { value: "complete-secret", onChange() {}, @@ -263,6 +350,9 @@ function reactProps(element: Element): Record { candidate.startsWith("__reactProps$"), ); return key - ? ((element as unknown as Record)[key] as Record) + ? ((element as unknown as Record)[key] as Record< + string, + unknown + >) : {}; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 302d644243..9d0a0b5447 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -99,8 +99,6 @@ test('resolves WorkHub coordination through the dedicated Host operation', async const { client, requests } = clientWithResponses([ { sessionId: 'maka_workhub_coordination' }, { candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }, - { disposition: 'answer_here', coordinationTurnId: 'action-turn' }, - { turnId: 'summary-turn' }, ]); assert.deepEqual(await client.resolveWorkHubCoordinationSession(), { @@ -110,41 +108,9 @@ test('resolves WorkHub coordination through the dedicated Host operation', async candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [], }); - assert.deepEqual( - await client.actWorkHubCoordination({ - actionId: 'action', - userText: 'Question', - proposal: { disposition: 'answer_here' }, - }), - { disposition: 'answer_here', coordinationTurnId: 'action-turn' }, - ); - assert.deepEqual( - await client.recordWorkHubCoordination({ - turnId: 'summary-turn', - userText: 'Request', - assistantText: 'Summary', - }), - { turnId: 'summary-turn' }, - ); assert.deepEqual(requests, [ { operation: 'workhub.coordination.resolve', input: {} }, { operation: 'workhub.coordination.candidates', input: {} }, - { - operation: 'workhub.coordination.act', - input: { - actionId: 'action', - userText: 'Question', - proposal: { disposition: 'answer_here' }, - }, - }, - { - operation: 'workhub.coordination.record', - input: { - turnId: 'summary-turn', - userText: 'Request', - assistantText: 'Summary', - }, - }, ]); }); @@ -452,7 +418,6 @@ test('treats empty configuration patches as read-only lookups', async () => { ]); }); - test('binds message controls to the current Host Epoch', async () => { const { client, requests } = clientWithResponses([ { disposition: 'steering', queueRevision: 2 }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 2747bd7778..f12f5cb93f 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -269,7 +269,7 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn computerUseTools: Object.assign([], { clearSession() {}, }) as unknown as ComputerUseToolSet, - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }, botRegistry: {} as BotRegistry, resolveBotCreateTarget: async () => ({ @@ -277,7 +277,7 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn }), resolveSessionCreateProject: async () => ({ kind: 'host_path', path: base }), emitSessionsChanged: (_hostId, reason, sessionId) => changes.push({ reason, sessionId }), - completeComputerUseTurn() {}, + completeDesktopInteractionTurn() {}, createSessionCopyCleanup: () => ({ ownCreation: (_creation, operation) => operation(), rejectCreation: async () => undefined, diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index 8b3dc61f06..bccc5ace56 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -405,7 +405,7 @@ test('rejects a stale Host identity when raw Session IDs collide', async () => { browserReleased.push(sessionId); }, computerUseTools: emptyComputerUseTools(), - releaseComputerUseSession: (sessionId) => { + releaseDesktopInteractionSession: (sessionId) => { computerReleased.push(sessionId); }, }; @@ -562,7 +562,7 @@ test('starts without registering an empty native capability set', async () => { resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: emptyComputerUseTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }), ); @@ -582,7 +582,7 @@ test('refreshes native capabilities with a new immutable provider snapshot', asy resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: emptyComputerUseTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => { const value = implementation; return [ @@ -639,7 +639,7 @@ test('releases all native Session resources on retirement and generation close', browserReleased.push(sessionId); }, computerUseTools: emptyComputerUseTools(), - releaseComputerUseSession: (sessionId) => { + releaseDesktopInteractionSession: (sessionId) => { computerReleased.push(sessionId); }, }), @@ -744,7 +744,7 @@ test('closes the claimed Host connection when native capability construction fai resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: emptyComputerUseTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }), ), /tool schema root must be an object/, @@ -776,7 +776,7 @@ test('isolates an invalid dynamic MCP tool without dropping the Host connection' resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: emptyComputerUseTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -818,7 +818,7 @@ test('does not release or report a Revision the Host retained during cleanup', a released.push(`browser:${sessionId}`); }, computerUseTools: emptyComputerUseTools(), - releaseComputerUseSession: (sessionId) => { + releaseDesktopInteractionSession: (sessionId) => { released.push(`computer:${sessionId}`); }, }), @@ -1255,7 +1255,7 @@ function deps( resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: emptyComputerUseTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }, ): DesktopRuntimeHostCandidateDeps { return { @@ -1271,7 +1271,7 @@ function deps( }), resolveSessionCreateProject: async () => ({ kind: 'host_path', path: '/workspace' }), emitSessionsChanged() {}, - completeComputerUseTurn() {}, + completeDesktopInteractionTurn() {}, createSessionCopyCleanup: () => ({ ownCreation: (_creation, operation) => operation(), rejectCreation: async () => undefined, diff --git a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts index 96833d86ee..8e1e9df174 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-native-capabilities.test.ts @@ -47,7 +47,7 @@ test('publishes self-described session-affine Browser and Computer Use offers', resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(async () => ({ text: 'ok' })), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }); assert.deepEqual( @@ -103,7 +103,7 @@ test('remote providers do not request Host paths and use a Client-owned cwd', as resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }, { hostPathAccess: 'none', clientCwd: '/client/runtime-host' }, ); @@ -124,7 +124,7 @@ test('publishes the real Computer Use schema through the Client Capability proto resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools, - releaseComputerUseSession: (sessionId) => computerUseTools.clearSession(sessionId), + releaseDesktopInteractionSession: (sessionId) => computerUseTools.clearSession(sessionId), }); assert.doesNotThrow(() => @@ -150,7 +150,7 @@ test('projects and publishes jsonSchema-wrapped MCP proxy tool descriptors', () resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -209,7 +209,7 @@ test('forwards JSON Schema native capability arguments to the MCP authority', as resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -256,7 +256,7 @@ test('skips non-object root jsonSchema tools without dropping the offer', () => resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -300,7 +300,7 @@ test('skips malformed record-shaped schemas without dropping healthy MCP tools', resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -341,7 +341,7 @@ test('skips unsupported schema type tools without dropping the offer', () => { resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -389,7 +389,7 @@ test('skips a malformed MCP tool without dropping the other offers', async () => resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -463,7 +463,7 @@ test('empty allOf/anyOf/oneOf are projected away so the schema still publishes', resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -520,7 +520,7 @@ test('publishes every production Desktop-owned tool schema through the protocol' resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_settings', @@ -553,7 +553,7 @@ test('publishes and admits additional Desktop native-effect services', async () resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalServices: (scope) => [ { serviceId: 'maka_scheduled_task_native_effect', @@ -622,7 +622,7 @@ test('validates before admission and invokes the exact offered tool with Host co }, releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }, { nativeSessionId: (sessionId) => `host-a:${sessionId}` }, ); @@ -677,7 +677,7 @@ test('does not execute Browser work when its Origin changes while admission is p resolveCount++ === 0 ? 'https://first.example/page' : 'https://second.example/page', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }); await assert.rejects( @@ -704,11 +704,11 @@ test('watches Computer Use turns without widening Browser lifecycle', async () = computerUseSessionId = context.sessionId; return { text: 'observed' }; }), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }, { onSessionUsed: (sessionId) => usedSessions.push(sessionId), - onComputerUseTurnUsed: (sessionId, turnId) => + onDesktopInteractionTurnUsed: (sessionId, turnId) => computerUseTurns.push([sessionId, turnId]), nativeSessionId: (sessionId) => `host-a:${sessionId}`, }, @@ -761,7 +761,7 @@ test('projects Computer Use screenshots and releases all native resources for a browserReleased.push(sessionId); }, computerUseTools, - releaseComputerUseSession: (sessionId) => computerUseTools.clearSession(sessionId), + releaseDesktopInteractionSession: (sessionId) => computerUseTools.clearSession(sessionId), }); await provider.releaseSession('manual-session'); @@ -817,7 +817,7 @@ test('does not advertise unavailable capability groups or dispatch unknown ident resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }); assert.deepEqual( provider.offers().map((offer) => offer.offerId), @@ -849,7 +849,7 @@ test('dispatches through the same immutable tool snapshot it advertised', async resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => additionalGroups, }); additionalGroups = [ @@ -898,7 +898,7 @@ test('chunks a dynamic capability group beyond the single-offer tool limit', asy resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: [] as never, - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -954,7 +954,7 @@ test('omits trailing dynamic tools beyond the manifest tool budget and keeps fix resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: [] as never, - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -999,7 +999,7 @@ test('omits trailing dynamic tools beyond the manifest byte budget', () => { resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: [] as never, - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -1038,7 +1038,7 @@ test('reports dynamic tools the decoder rejects instead of dropping them silentl resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: [] as never, - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -1073,7 +1073,7 @@ test('publishes identified tools under their real normalized MCP identity', asyn resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: [] as never, - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp_fixture', @@ -1140,7 +1140,7 @@ test('chunks and degrades a dynamic capability group deterministically', () => { resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: [] as never, - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, additionalGroups: () => [ { offerId: 'desktop_mcp', @@ -1166,7 +1166,7 @@ test('fails loudly when a fixed capability group exceeds the manifest budget', ( resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: [] as never, - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }), /Invalid Client Capability offer tools/u, ); @@ -1180,7 +1180,7 @@ test('reports provider retirement once after its registration is released', asyn resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }, { onClosed: () => { @@ -1208,7 +1208,7 @@ test('settles every native Session cleanup before reporting a release failure', throw new Error('browser release failed'); }, computerUseTools: computerTools(), - async releaseComputerUseSession() { + async releaseDesktopInteractionSession() { await computerRelease; computerReleased = true; }, @@ -1243,7 +1243,7 @@ test('forwards Host cancellation to an admitted Desktop invocation', async () => resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, computerUseTools: computerTools(), - releaseComputerUseSession() {}, + releaseDesktopInteractionSession() {}, }); const controller = new AbortController(); if (!provider.call) throw new Error('Expected a callable provider'); @@ -1375,3 +1375,25 @@ async function call( requestInteraction: async () => assert.fail('Unexpected provider interaction'), }); } + +test('WorkHub groups receive their target epoch and join Desktop interaction turn lifecycle', async () => { + const scope = { hostId: 'host', targetEpoch: 'epoch' }; + const watched: string[][] = []; + const provider = createDesktopNativeCapabilityProvider({ + browserTools: [], resolveBrowserUrl: () => 'https://example.com/', releaseBrowserSession() {}, + computerUseTools: computerTools(), releaseDesktopInteractionSession() {}, + additionalGroups: received => { + assert.deepEqual(received, scope); + return [{ offerId: 'desktop_workhub', label: 'WorkHub', description: 'WorkHub', tools: [tool('control', z.object({}), async (_input, ctx) => ctx.sessionId)] }]; + }, + }, { + targetScope: scope, + nativeSessionId: sessionId => `native:${sessionId}`, + onDesktopInteractionTurnUsed: (sessionId, turnId) => { watched.push([sessionId, turnId]); }, + }); + const frame = capabilityFrame({ offerId: 'desktop_workhub', serverId: 'desktop_workhub', toolName: 'control', arguments: {} }); + const result = await call(provider, frame); + assert.deepEqual(watched, [[frame.sessionId, frame.turnId]]); + assert.deepEqual(result.content, [{ type: 'text', text: frame.sessionId }], 'WorkHub authority sees the real Host Session id, not a native resource alias'); + await provider.close(); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index 0f08bffd35..e736c0787a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -58,6 +58,42 @@ test('registers Session observation as one reconnectable operation', () => { assert.equal(ipc.reconnectableChannels.has('sessions:observe'), true); }); +for (const phase of ['connecting', 'seeding'] as const) { + for (const cancellation of ['unobserve', 'renderer destruction'] as const) { + test(`Session observation IPC completes normally after ${cancellation} while ${phase}`, async () => { + const errors: unknown[] = []; + const observations = new RuntimeHostSessionObservationRegistry((error) => errors.push(error)); + const ipc = ipcHarness(); + let finishSeed = () => {}; + let seeds = 0; + const source = { + observe: () => { + seeds += 1; + return new Promise((resolve) => { finishSeed = resolve; }); + }, + async unobserve() {}, + }; + if (phase === 'seeding') await observations.attach(source); + registerRuntimeHostSessionObservationIpc({ observations, resolveSideConversation: async () => false }, ipc); + const observing = ipc.invoke('sessions:observe', 'session-1', 'observer-1'); + void observing.catch(() => undefined); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(observations.observedSessionIds(), ['session-1']); + + if (cancellation === 'unobserve') await observations.unobserve('observer-1'); + else ipc.rendererDestroyed(); + finishSeed(); + + assert.deepEqual(await observing, []); + assert.deepEqual(observations.observedSessionIds(), []); + assert.deepEqual(await observations.attach(source), []); + assert.equal(seeds, phase === 'seeding' ? 1 : 0); + assert.deepEqual(errors, []); + await observations.close(); + }); + } +} + test('forward transcript paging is an observation operation scoped to the renderer', async () => { const ipc = ipcHarness(); const observations = new RuntimeHostSessionObservationRegistry(); diff --git a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts deleted file mode 100644 index 33e8313b62..0000000000 --- a/apps/desktop/src/main/__tests__/runtime-host-workhub-ipc-main.test.ts +++ /dev/null @@ -1,164 +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 assert from 'node:assert/strict'; -import test from 'node:test'; -import { RuntimeHostOperationError } from '@maka/runtime-host/client'; -import { registerRuntimeHostWorkHubIpc } from '../runtime-host-workhub-ipc-main.js'; - -test('projects WorkHub coordination resolution through its dedicated IPC domain', async () => { - const handlers = new Map unknown>(); - let resolveCalls = 0; - const records: unknown[] = []; - const actions: unknown[] = []; - const changes: unknown[] = []; - const createdSessionId = 'runtime-created-session'; - registerRuntimeHostWorkHubIpc( - { - resolveWorkHubCoordinationSession: async () => { - resolveCalls += 1; - return { sessionId: 'maka_workhub_coordination' }; - }, - recordWorkHubCoordination: async (input: { - turnId: string; - userText: string; - assistantText: string; - }) => { - records.push(input); - return { turnId: input.turnId }; - }, - listWorkHubCoordinationCandidates: async () => ({ - candidateSetId: `sha256:${'a'.repeat(64)}`, - candidates: [], - }), - actWorkHubCoordination: async (input: unknown) => { - actions.push(input); - return { - disposition: 'create_new', - targetSessionId: createdSessionId, - targetTurnId: 'created-turn', - }; - }, - } as never, - { - handle: (channel: string, handler: (...args: unknown[]) => unknown) => { - handlers.set(channel, handler); - }, - } as never, - { - resolveCreateProject: async () => ({ - kind: 'host_path', - path: '/tmp/workhub-project', - }), - emitSessionsChanged: (reason, sessionId) => changes.push({ reason, sessionId }), - }, - ); - - const handler = handlers.get('workhub:resolveCoordinationSession'); - assert.ok(handler); - assert.deepEqual(await handler({}), { sessionId: 'maka_workhub_coordination' }); - assert.equal(resolveCalls, 1); - assert.deepEqual( - await handlers.get('workhub:record')?.({}, { - turnId: 'record', - userText: 'Request', - assistantText: 'Summary', - }), - { turnId: 'record' }, - ); - assert.deepEqual(records, [{ - turnId: 'record', - userText: 'Request', - assistantText: 'Summary', - }]); - assert.deepEqual(await handlers.get('workhub:candidates')?.({}), { - candidateSetId: `sha256:${'a'.repeat(64)}`, - candidates: [], - }); - assert.deepEqual( - await handlers.get('workhub:act')?.({}, { - actionId: 'create-action', - newWorkDefaults: { permissionMode: 'bypass' }, - userText: 'Start accessibility review', - proposal: { disposition: 'create_new', title: 'Accessibility review' }, - create: { - sessionId: 'renderer-invented', - workspace: { kind: 'host_path', path: '/renderer-path' }, - }, - }), - { - ok: true, - result: { - disposition: 'create_new', - targetSessionId: createdSessionId, - targetTurnId: 'created-turn', - }, - }, - ); - assert.deepEqual(actions, [{ - actionId: 'create-action', - newWorkDefaults: { permissionMode: 'bypass' }, - userText: 'Start accessibility review', - proposal: { disposition: 'create_new', title: 'Accessibility review' }, - create: { - workspace: { kind: 'host_path', path: '/tmp/workhub-project' }, - }, - }]); - assert.deepEqual(changes, [{ reason: 'created', sessionId: createdSessionId }]); -}); - -test('serializes typed WorkHub action failures across Electron IPC', async () => { - const handlers = new Map unknown>(); - registerRuntimeHostWorkHubIpc( - { - actWorkHubCoordination: async () => { - throw new RuntimeHostOperationError( - 'workhub.coordination.act', - 'operation_conflict', - 'WorkHub action is permanently abandoned', - ); - }, - } as never, - { - handle: (channel: string, handler: (...args: unknown[]) => unknown) => { - handlers.set(channel, handler); - }, - } as never, - { - resolveCreateProject: async () => ({ kind: 'host_path', path: '/workspace' }), - emitSessionsChanged: () => undefined, - }, - ); - - assert.deepEqual( - await handlers.get('workhub:act')?.({}, { - actionId: 'abandoned-action', - userText: 'Continue payment work', - candidateSetId: `sha256:${'a'.repeat(64)}`, - proposal: { disposition: 'delegate_existing', candidateRef: 'candidate' }, - }), - { - ok: false, - error: { - code: 'operation_conflict', - message: 'WorkHub action is permanently abandoned', - }, - }, - ); -}); diff --git a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts index 9fa9ea37b5..2dff394ff4 100644 --- a/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-navigation-race.test.ts @@ -22,7 +22,7 @@ import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; import { SESSION_CONTINUITY_SCHEMA_VERSION, type SessionTranscriptPage } from '@maka/runtime-host/protocol'; import type { DesktopTranscriptBatch, DesktopTranscriptHandle, DesktopTranscriptNavigation, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; -import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/desktop-transcript-range-store.js'; +import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { DesktopTranscriptReplica } from '../desktop-transcript-replica.js'; import { RuntimeHostSessionObserver } from '../runtime-host-session-observer.js'; diff --git a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts index d664187d80..c2d53b6873 100644 --- a/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-overlay-settlement.test.ts @@ -32,7 +32,7 @@ import { readSessionTranscriptPage, updateSubscriberTranscriptHighWater, } from '../../../../../packages/runtime-host/dist/server/session-transcript-pager.js'; -import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/desktop-transcript-range-store.js'; +import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import type { DesktopTranscriptNavigation } from '../../preload/transcript-contract.js'; import { encodeDesktopTranscriptChange, encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; import { DesktopTranscriptReplica, type DesktopTranscriptReplicaChange } from '../desktop-transcript-replica.js'; diff --git a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts index f538a08e9e..8b16db2505 100644 --- a/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-reading-position-controller.test.ts @@ -24,7 +24,7 @@ import { deferred } from '@maka/core/test-only/async-primitives'; import type { StoredMessage } from '@maka/core/session'; import type { DesktopTranscriptHandle, DesktopTranscriptNavigation } from '../../preload/transcript-contract.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; -import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/desktop-transcript-range-store.js'; +import { createDesktopTranscriptRangeController, DesktopTranscriptRangeStore } from '../../renderer/platform/desktop/desktop-transcript-range-store.js'; import { createAppShellSessionUiStateController, TranscriptReadingPositionController, diff --git a/apps/desktop/src/main/__tests__/windows-app-tray.test.ts b/apps/desktop/src/main/__tests__/windows-app-tray.test.ts new file mode 100644 index 0000000000..01f38671e9 --- /dev/null +++ b/apps/desktop/src/main/__tests__/windows-app-tray.test.ts @@ -0,0 +1,116 @@ +/* + * 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 assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; +import type { Menu, MenuItemConstructorOptions } from 'electron'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { createWindowsAppTray } from '../windows-app-tray.js'; + +function fixture(platform: NodeJS.Platform = 'win32', enabled = true) { + let locale: UiLocale = 'en'; + let localeChanged: (() => void) | undefined; + let template: MenuItemConstructorOptions[] = []; + let failMenu = false; + let creations = 0; + let releases = 0; + const actions: string[] = []; + const errors: unknown[] = []; + class FakeTray extends EventEmitter { + destroyed = false; + tooltip = ''; + setToolTip(value: string) { this.tooltip = value; } + setContextMenu() { if (failMenu) throw new Error('Tray menu failed'); } + isDestroyed() { return this.destroyed; } + destroy() { this.destroyed = true; } + } + let surface: FakeTray | undefined; + const tray = createWindowsAppTray({ + platform, enabled, + locale: { + current: () => locale, + subscribe: (handler) => { localeChanged = () => handler(locale); return () => { releases++; localeChanged = undefined; }; }, + }, + createTray: () => { creations++; surface = new FakeTray(); return surface as never; }, + createMenu: (items) => { template = items; return {} as Menu; }, + openMain: () => { actions.push('main'); }, + openWorkHub: () => { actions.push('workhub'); }, + quit: () => { actions.push('quit'); }, + onError: (error) => errors.push(error), + }); + return { + tray, actions, errors, + surface: () => surface, + labels: () => template.map((item) => item.label), + choose: (index: number) => (template[index]?.click as (() => void) | undefined)?.(), + setLocale: (next: UiLocale) => { locale = next; localeChanged?.(); }, + failMenu: () => { failMenu = true; }, + creations: () => creations, + releases: () => releases, + }; +} + +test('Windows tray reopens either surface and reserves quit for the explicit menu action', async () => { + const f = fixture(); + assert.equal(f.tray.start(), true); + assert.equal(f.tray.start(), true); + assert.equal(f.creations(), 1); + f.surface()!.emit('double-click'); + f.choose(1); + await Promise.resolve(); + assert.deepEqual(f.actions, ['main', 'workhub']); + assert.equal(f.tray.hasTray(), true); + f.choose(3); + await Promise.resolve(); + assert.deepEqual(f.actions, ['main', 'workhub', 'quit']); + f.tray.dispose(); + f.tray.dispose(); + assert.equal(f.tray.hasTray(), false); + assert.equal(f.surface()!.destroyed, true); + assert.equal(f.releases(), 1); + assert.equal(f.tray.start(), false); +}); + +test('tray follows the current native UI locale', () => { + const f = fixture(); + f.tray.start(); + assert.equal(f.labels()[0], 'Open Maka'); + f.setLocale('zh-CN'); + assert.equal(f.labels()[3], '退出 Maka'); + f.setLocale('zh-TW'); + assert.equal(f.labels()[1], '開啟 WorkHub'); + f.tray.dispose(); +}); + +test('failed native setup never claims that a background entry exists', () => { + const f = fixture(); + f.failMenu(); + assert.equal(f.tray.start(), false); + assert.equal(f.tray.hasTray(), false); + assert.equal(f.surface()!.destroyed, true); + assert.equal(f.errors.length, 1); +}); + +test('macOS and isolated runs do not create another tray entry', () => { + for (const f of [fixture('darwin'), fixture('win32', false)]) { + assert.equal(f.tray.start(), false); + assert.equal(f.creations(), 0); + } +}); diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index c44f0a7ccc..cb2c268c65 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -121,6 +121,7 @@ function input( ): UseWorkbarControllerInput { return { available: true, + layoutSessionId: activeSession?.id, activeSession, projectId: activeSession?.projectId, projectAliases: [], @@ -139,6 +140,20 @@ describe('useWorkbarController', () => { delete (globalThis as { window?: unknown }).window; }); + it('preserves an expansion requested before the Host-backed Session arrives', async () => { + const { root } = installReactRenderer(); + const services = createFakeWorkbarServices(); + await act(async () => renderController(root, services, { + ...input(undefined), layoutSessionId: 'pending', + })); + await act(async () => controller().commands.toggleRight()); + assert.equal(controller().host.rightCollapsed, false); + assert.equal(controller().host.activeId, undefined); + await act(async () => renderController(root, services, input(session('pending')))); + assert.equal(controller().host.activeId, 'pending'); + assert.equal(controller().host.rightCollapsed, false); + }); + it('keeps right-panel visibility independent across Session navigation', async () => { const { root } = installReactRenderer(); const services = createFakeWorkbarServices(); diff --git a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts index e8cedaa108..bef78755cb 100644 --- a/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-anchor-rail.test.ts @@ -21,21 +21,39 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import type { WorkHubSessionSummary } from "../../renderer/workhub-controller.js"; +import type { WorkHubAnchorSession } from "../../renderer/features/workhub/index.js"; import { deriveWorkHubAnchors, matchesWorkHubFilter, MAX_WORKHUB_ANCHORS, WorkHubNavigationRail, + workHubLinkedWork, } from "../../renderer/features/workhub/index.js"; import { getWorkHubRailCopy } from "../../renderer/locales/workhub-copy.js"; +import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session'; + +test('durable task results restore Host-scoped work links without treating failed or unrelated tools as delegations', () => { + const target = JSON.stringify(['host-a', 'task-a']); + const call: ToolCallMessage = { type: 'tool_call', id: 'task-call', turnId: 'turn', ts: 1, toolName: 'mcp__desktop_workhub__tasks', args: {} }; + const result: ToolResultMessage = { type: 'tool_result', id: 'task-result', turnId: 'turn', ts: 2, toolUseId: call.id, isError: false, content: { kind: 'json', value: { disposition: 'create_new', targetSessionKey: target } } }; + const expected = [{ id: result.id, coordinationTurnId: call.turnId, targetSessionId: target, targetSessionName: 'Renamed task' }]; + assert.deepEqual(workHubLinkedWork([call, result], [{ id: target, name: 'Renamed task' }], 'Work'), expected); + assert.deepEqual(workHubLinkedWork([call, { ...result, content: { kind: 'json', value: { content: [], structuredContent: { disposition: 'create_new', targetSessionKey: target } } } }], [{ id: target, name: 'Renamed task' }], 'Work'), expected); + assert.deepEqual(workHubLinkedWork([call, { ...result, content: { kind: 'text', text: JSON.stringify({ disposition: 'delegate_existing', targetSessionKey: target }) } }], [], 'Work'), [{ ...expected[0], targetSessionName: 'Work' }]); + assert.deepEqual(workHubLinkedWork([ + call, + { ...result, isError: true }, + { ...result, toolUseId: 'other-tool' }, + { ...result, content: { kind: 'json', value: { disposition: 'stop_work', targetSessionKey: target } } }, + ], [], 'Work'), []); +}); function session( sessionId: string, - state: WorkHubSessionSummary["state"], + state: WorkHubAnchorSession["state"], updatedAt: number, archived = false, -): WorkHubSessionSummary { +): WorkHubAnchorSession { return { target: { sessionId }, projectName: "Maka", diff --git a/apps/desktop/src/main/__tests__/workhub-control-input.test.ts b/apps/desktop/src/main/__tests__/workhub-control-input.test.ts new file mode 100644 index 0000000000..75492ab8a2 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-control-input.test.ts @@ -0,0 +1,82 @@ +/* + * 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 assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { WorkHubControlOverlay, WorkHubServicesProvider, type WorkHubServices } from '../../renderer/features/workhub/index.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(cleanupFakeDom); + +test('moving the pointer does not take control; user clicks, scrolling and non-modifier keys do', async (t) => { + const { root } = installReactRenderer(); + Object.defineProperty(document.documentElement, 'classList', { value: { remove() {} } }); + const listeners = new Map(); + t.mock.method(window, 'addEventListener', (type: string, listener: EventListener) => { listeners.set(type, listener); }); + t.mock.method(window, 'removeEventListener', (type: string) => { listeners.delete(type); }); + class PointerInput extends Event { + readonly isTrusted = true; + clientX = 10; + clientY = 20; + movementX = 4; + movementY = 2; + } + class WheelInput extends Event { readonly isTrusted = true; } + class KeyInput extends Event { + readonly isTrusted = true; + constructor(readonly key: string) { super('keydown'); } + } + for (const [name, value] of [['PointerEvent', PointerInput], ['WheelEvent', WheelInput]] as const) { + const original = Object.getOwnPropertyDescriptor(globalThis, name); + Object.defineProperty(globalThis, name, { configurable: true, value }); + t.after(() => { + if (original) Object.defineProperty(globalThis, name, original); + else Reflect.deleteProperty(globalThis, name); + }); + } + let stops = 0; + const services = { control: { + getSnapshot: async () => ({ revision: 1, phase: 'acting', canUndo: false, cursor: { x: 10, y: 20, clicking: false } }), + subscribe: () => () => {}, + stop: async () => { stops++; }, + } } as unknown as WorkHubServices; + await act(async () => { root.render(createElement(WorkHubServicesProvider, { services }, createElement(WorkHubControlOverlay))); }); + const dispatch = async (event: Event) => { await act(async () => { listeners.get(event.type)?.(event); }); }; + await dispatch(new PointerInput('pointermove')); + await dispatch(new KeyInput('Shift')); + await dispatch(new Event('pointerdown')); + assert.equal(stops, 0); + await dispatch(new CustomEvent('maka-assistant:input', { detail: { x: 10, y: 20 } })); + await dispatch(new PointerInput('pointermove')); + await dispatch(new PointerInput('pointerdown')); + assert.equal(stops, 0, 'the marked agent click must not interrupt itself'); + await dispatch(new PointerInput('pointerdown')); + assert.equal(stops, 1); + await dispatch(new CustomEvent('maka-assistant:input', { detail: { wheel: true } })); + await dispatch(new WheelInput('wheel')); + assert.equal(stops, 1); + await dispatch(new WheelInput('wheel')); + assert.equal(stops, 2); + await dispatch(new CustomEvent('maka-assistant:input', { detail: { key: 'Enter' } })); + await dispatch(new KeyInput('Enter')); + assert.equal(stops, 2); + await dispatch(new KeyInput('a')); + assert.equal(stops, 3); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-control.test.ts b/apps/desktop/src/main/__tests__/workhub-control.test.ts new file mode 100644 index 0000000000..2b720fb6b1 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-control.test.ts @@ -0,0 +1,495 @@ +/* + * 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 assert from "node:assert/strict"; +import test from "node:test"; +import type { IpcMain, WebContents } from "electron"; +import { createDefaultSettings } from "@maka/core/settings"; +import { WORKHUB_COORDINATION_SESSION_ID } from "@maka/core/session"; +import { createWorkHubControl } from "../workhub-control.js"; +import { WorkHubSurface } from "../workhub-surface.js"; +import { WorkHubUi } from "../workhub-ui.js"; +import type { DesktopRuntimeHostClient } from "../runtime-host-client.js"; +import { desktopSessionResourceKey } from "../../shared/runtime-host-identity.js"; +import type { MakaTool } from "@maka/runtime/tool-runtime"; + +const scope = { hostId: "host", targetEpoch: "epoch" }; +function harness(prepareWindow: () => Promise = async () => {}) { + let command!: Parameters[1]; + let current = true; + let activeTurn = "turn"; + const interrupted: string[] = []; + const window = { + mainFrame: {}, + isDestroyed: () => false, + send() {}, + } as unknown as WebContents; + const control = createWorkHubControl({ + ipcMain: { + handle: (_name, handler) => { + command = handler; + }, + removeHandler() {}, + }, + window: () => window, + prepareWindow, + authorizedRenderer: (contents) => contents === window, + send() {}, + readSettings: async () => createDefaultSettings(), + client: () => ({}) as DesktopRuntimeHostClient, + isCurrent: () => current, + assertTurn: async (_scope, turnId) => { + if (turnId !== activeTurn) throw new Error("Inactive turn"); + }, + interrupt: async (_scope, turnId) => { + interrupted.push(turnId); + }, + actTasks: async (_scope, turnId, callId, input) => ({ + turnId, + callId, + input, + }), + }); + const tool = control.group(scope).tools[0] as MakaTool; + const ctx = (turnId = activeTurn) => ({ + sessionId: WORKHUB_COORDINATION_SESSION_ID, + turnId, + toolCallId: "call", + cwd: "/tmp", + abortSignal: new AbortController().signal, + emitOutput() {}, + }); + return { + control, + tool, + ctx, + interrupted, + setCurrent: (value: boolean) => { + current = value; + }, + setTurn: (value: string) => { + activeTurn = value; + }, + command: (name: string) => + command( + { + sender: window, + senderFrame: window.mainFrame, + } as Electron.IpcMainInvokeEvent, + name, + ), + }; +} + +test("control rejects ordinary Sessions, stale turns and switched Host epochs before observation", async (t) => { + const h = harness(); + t.after(() => h.control.close()); + t.mock.method(WorkHubUi.prototype, "observe", async () => { + throw new Error("Must not observe"); + }); + await assert.rejects( + async () => + h.tool.impl( + { request: { operation: "observe" } }, + { ...h.ctx(), sessionId: "ordinary" }, + ), + /Only the active WorkHub/, + ); + await assert.rejects( + async () => h.tool.impl({ request: { operation: "observe" } }, h.ctx("stale")), + /Inactive turn/, + ); + h.setCurrent(false); + await assert.rejects( + async () => h.tool.impl({ request: { operation: "observe" } }, h.ctx()), + /Host changed/, + ); +}); + +test("partial batches never replay input and the failure budget belongs to the real turn", async (t) => { + const h = harness(); + t.after(() => h.control.close()); + t.mock.method(WorkHubUi.prototype, "observe", async () => ({ + section: null, + language: "en", + theme: "light", + accessibility: "", + controls: [], + })); + t.mock.method(WorkHubUi.prototype, "begin", async () => {}); + let attempts = 0; + let failAfterInput = false; + t.mock.method( + WorkHubUi.prototype, + "execute", + async function (this: WorkHubUi) { + attempts++; + if (attempts === 1) return { verified: false, dispatched: true }; + if (failAfterInput) this.dispatchedInputs++; + throw new Error("Control changed"); + }, + ); + const act = async (refs = ["current"]) => + (await h.tool.impl( + { + request: { + operation: "act", + actions: refs.map((ref) => ({ kind: "click", ref })), + }, + }, + h.ctx(), + )) as { + completed: unknown[]; + recoverable: boolean; + inputDispatched: boolean; + requiresNewTurn?: boolean; + }; + const partial = await act(["one", "two", "three"]); + assert.equal(partial.completed.length, 1); + assert.equal(attempts, 2); + assert.equal(partial.inputDispatched, false); + assert.equal((await h.command("snapshot")).error, "Control changed"); + failAfterInput = true; + assert.equal((await act()).inputDispatched, true); + assert.equal((await act()).recoverable, false); + assert.equal((await act()).requiresNewTurn, true); + assert.equal(attempts, 4); + h.control.complete( + desktopSessionResourceKey({ + ...scope, + sessionId: WORKHUB_COORDINATION_SESSION_ID, + }), + ); + h.setTurn("new-turn"); + assert.equal((await act()).recoverable, true); +}); + +test("takeover cancels waiting and interrupts the exact owning turn", async (t) => { + const h = harness(); + t.after(() => h.control.close()); + const pending = assert.rejects( + async () => h.tool.impl({ request: { operation: "observe", waitMs: 5000 } }, h.ctx()), + /abort|User took control/i, + ); + await new Promise((resolve) => setImmediate(resolve)); + await h.command("stop"); + await pending; + assert.deepEqual(h.interrupted, ["turn"]); + await assert.rejects( + async () => h.tool.impl({ request: { operation: "observe" } }, h.ctx()), + /User took control/, + ); +}); + +test("observations exclude browser, terminal, password and marked descendants and reject replaced or stale handles", async () => { + const surface = new WorkHubSurface(); + const metadata = { + ref: "fresh", + name: "Rename", + role: "", + tag: "button", + type: "", + section: "", + navigation: false, + external: false, + editable: false, + }; + let preparing = true; + let backend = 2; + const ax = (id: number, name: string, role = "button") => ({ + nodeId: String(id), + backendDOMNodeId: id, + name: { value: name }, + role: { value: role }, + }); + const wc = { + executeJavaScript: async () => (preparing ? [{ ...metadata }] : true), + debugger: { + sendCommand: async (method: string) => { + if (method === "DOM.getDocument") return { root: { nodeId: 1 } }; + if (method === "DOM.querySelector") return { nodeId: 2 }; + if (method === "DOM.describeNode") + return { node: { backendNodeId: backend } }; + return { nodes: [ax(2, "Rename")] }; + }, + }, + } as unknown as WebContents; + await surface.prepare(wc); + const result = surface.filter( + { + nodeName: "HTML", + backendNodeId: 1, + children: [ + { + nodeName: "BUTTON", + backendNodeId: 2, + attributes: ["data-maka-assistant-ref", "fresh"], + }, + { + nodeName: "DIV", + backendNodeId: 3, + attributes: ["data-maka-assistant-exclude", ""], + children: [{ nodeName: "BUTTON", backendNodeId: 4 }], + }, + { + nodeName: "IFRAME", + backendNodeId: 5, + children: [{ nodeName: "BUTTON", backendNodeId: 6 }], + }, + { + nodeName: "INPUT", + backendNodeId: 7, + attributes: ["type", "password"], + }, + { + nodeName: "DIV", + backendNodeId: 8, + attributes: ["class", "xterm"], + children: [{ nodeName: "BUTTON", backendNodeId: 9 }], + }, + ], + }, + { + nodes: [ + ax(2, "Rename"), + ...[3, 4, 5, 6, 7, 8, 9].map((id) => ax(id, "excluded content")), + ], + }, + ); + assert.equal(JSON.stringify(result).includes("excluded content"), false); + assert.deepEqual( + surface.list().map((entry) => entry.name), + ["Rename"], + ); + preparing = false; + assert.equal((await surface.resolve(wc, "fresh", "click")).name, "Rename"); + await assert.rejects(surface.resolve(wc, "fresh", "type"), /not an editable/); + await assert.rejects( + surface.resolve(wc, "fresh", "key"), + /requires an editor/, + ); + backend = 20; + await assert.rejects(surface.resolve(wc, "fresh", "click"), /replaced/); + preparing = true; + await surface.prepare(wc); + await assert.rejects(surface.resolve(wc, "fresh", "click"), /Stale/); +}); + +test("native input passes through only the assistant and restores hit testing after failure", async () => { + let passing = false; + const wc = { + isDestroyed: () => false, + executeJavaScript: async (script: string) => { + if (script.includes("getComputedStyle")) return null; + if (script.includes("getBoundingClientRect")) return { x: 20, y: 20 }; + if (script.startsWith("!!document.querySelector")) return true; + if (script.includes("classList.add('desktopAssistantInput')")) + passing = true; + if (script.includes("classList.remove('desktopAssistantInput')")) + passing = false; + }, + sendInputEvent: (event: { type: string }) => { + assert.equal(passing, true); + if (event.type === "mouseDown") throw new Error("Injected input failure"); + }, + } as unknown as WebContents; + const ui = new WorkHubUi( + () => wc, + async () => createDefaultSettings(), + () => {}, + async () => "", + ); + await assert.rejects( + ui.execute( + { kind: "navigate", section: "general" }, + new AbortController().signal, + ), + /Injected input failure/, + ); + assert.equal(passing, false); +}); + +test("typed Settings navigation reveals clipped sidebar controls through native wheel input", async () => { + const events: { type: string; deltaY?: number }[] = []; + let clipped = true; + const wc = { + isDestroyed: () => false, + executeJavaScript: async (script: string) => { + if (script.includes("getComputedStyle")) + return clipped ? { x: 80, y: 120, deltaY: -340 } : null; + if (script.includes("getBoundingClientRect")) return { x: 80, y: 120 }; + if (script.startsWith("!!document.querySelector")) return true; + }, + sendInputEvent: (event: { type: string; deltaY?: number }) => { + events.push(event); + if (event.type === "mouseWheel") clipped = false; + if (event.type === "mouseDown") + throw new Error("Reached revealed section"); + }, + } as unknown as WebContents; + const ui = new WorkHubUi( + () => wc, + async () => createDefaultSettings(), + () => {}, + async () => "", + ); + await assert.rejects( + ui.execute( + { kind: "navigate", section: "general" }, + new AbortController().signal, + ), + /Reached revealed section/, + ); + assert.equal( + events.find((event) => event.type === "mouseWheel")?.deltaY, + -340, + ); + assert.ok( + events.findIndex((event) => event.type === "mouseWheel") < + events.findIndex((event) => event.type === "mouseDown"), + ); + assert.equal( + ui.dispatchedInputs, + 2, + "wheel and click both count as dispatched input", + ); +}); + +test("window preparation cannot admit input after takeover or a Host switch", async (t) => { + let calls = 0; + t.mock.method(WorkHubUi.prototype, "observe", async () => { + calls++; + return { + section: null, + language: "en", + theme: "light", + accessibility: "", + controls: [], + }; + }); + t.mock.method(WorkHubUi.prototype, "begin", async () => { + calls++; + }); + t.mock.method(WorkHubUi.prototype, "execute", async () => { + calls++; + return { verified: true }; + }); + for (const cause of ["takeover", "host"] as const) { + let ready!: () => void; + let preparing!: () => void; + const entered = new Promise((resolve) => { + preparing = resolve; + }); + const gate = new Promise((resolve) => { + ready = resolve; + }); + const h = harness(async () => { + preparing(); + await gate; + }); + t.after(() => h.control.close()); + const pending = assert.rejects( + async () => + h.tool.impl( + { + request: { + operation: "act", + actions: [{ kind: "navigate", section: "general" }], + }, + }, + h.ctx(), + ), + /User took control|Host changed/, + ); + await entered; + if (cause === "takeover") await h.command("stop"); + else h.setCurrent(false); + ready(); + await pending; + } + assert.equal( + calls, + 0, + "preparing a visible main window grants no input authority", + ); +}); + +test("task coordination does not open or focus the controlled main window", async (t) => { + const h = harness(async () => { + throw new Error("Must not prepare the window"); + }); + t.after(() => h.control.close()); + const tasks = h.control.group(scope).tools[1] as MakaTool; + assert.deepEqual(await tasks.impl({ request: { operation: "candidates" } }, h.ctx()), { + turnId: "turn", + callId: "call", + input: { operation: "candidates" }, + }); +}); + + +test("takeover stops an action without exposing its internal abort reason as a conversation error", async (t) => { + const h = harness(); + t.after(() => h.control.close()); + t.mock.method(WorkHubUi.prototype, "begin", async () => {}); + let entered!: () => void; + const executing = new Promise((resolve) => { entered = resolve; }); + t.mock.method(WorkHubUi.prototype, "execute", async (_action: Parameters[0], signal: AbortSignal) => { + entered(); + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + const action = h.tool.impl({ request: { operation: "act", actions: [{ kind: "navigate", section: "general" }] } }, h.ctx()); + await executing; + await h.command('stop'); + const result = await action as { interrupted: boolean; error: string }; + assert.equal(result.interrupted, true); + assert.equal(result.error, 'User took control', 'the model still receives the interruption reason'); + const snapshot = await h.command('snapshot'); + assert.equal(snapshot.phase, 'paused'); + assert.equal(snapshot.error, undefined); + assert.equal(snapshot.cursor, undefined); + assert.deepEqual(h.interrupted, ['turn']); +}); + +test("cancelling undo is a normal completion for the renderer", async (t) => { + const h = harness(); + t.after(() => h.control.close()); + t.mock.method(WorkHubUi.prototype, "begin", async () => {}); + t.mock.method(WorkHubUi.prototype, "observe", async () => ({ section: null, language: 'en', theme: 'light', accessibility: '', controls: [] })); + let entered!: () => void; + const executing = new Promise((resolve) => { entered = resolve; }); + let calls = 0; + t.mock.method(WorkHubUi.prototype, "execute", async (_action: Parameters[0], signal: AbortSignal) => { + if (++calls === 1) return { previous: 'dark', verified: true }; + entered(); + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + await h.tool.impl({ request: { operation: 'act', actions: [{ kind: 'set', target: 'theme', value: createDefaultSettings().appearance.theme }] } }, h.ctx()); + h.control.complete(desktopSessionResourceKey({ ...scope, sessionId: WORKHUB_COORDINATION_SESSION_ID })); + const undoing = h.command('undo'); + await executing; + await h.command('stop'); + await undoing; + assert.equal((await h.command('snapshot')).error, undefined); + assert.equal((await h.command('snapshot')).phase, 'paused'); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts b/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts deleted file mode 100644 index e0dcbb3c1a..0000000000 --- a/apps/desktop/src/main/__tests__/workhub-controller-fixture.ts +++ /dev/null @@ -1,232 +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 { WorkHubCoordinationActInput } from '@maka/runtime-host/protocol'; -import { createWorkHubController as createGatedWorkHubController, type WorkHubSessionFacts, type WorkHubSessionPort, type WorkHubCoordinationTurn, type WorkHubSubmission } from '../../renderer/workhub-controller.js'; -import { createWorkHubR24RoutingStrategy, createWorkHubR3ARoutingStrategy, createWorkHubR3BRoutingStrategy, type WorkHubRoutingModelPort, type WorkHubRoutingStrategy } from '../../renderer/features/workhub/index.js'; - -export function session( - sessionId: string, - overrides: Partial = {}, -): WorkHubSessionFacts { - return { - target: { sessionId }, - projectName: 'maka', - sessionName: sessionId, - kind: 'ordinary', - archived: false, - state: 'active', - updatedAt: 1, - ...overrides, - }; -} - -export interface TestSessionPort extends WorkHubSessionPort { - create(input: { name: string }): Promise; - submit( - target: { sessionId: string }, - text: string, - turnId: string, - ): Promise<{ turnId: string; steered?: true }>; -} - -export function port(sessions: WorkHubSessionFacts[]): TestSessionPort { - let nextTurnId = 0; - return { - list: async () => sessions, - recentTurns: async () => [], - delegationFeedback: async (references) => - references.map(({ delegationId }) => ({ delegationId, state: 'accepted' })), - routingEvidence: async () => [], - create: async () => { - throw new Error('create is not used by this read test'); - }, - submit: async (_target, _text, turnId) => ({ - turnId: turnId || `reserved-turn-${++nextTurnId}`, - }), - subscribe: () => () => {}, - }; -} - -export function createWorkHubController({ - sessions, - routingStrategy, - transcript = [], - candidateSetId = `sha256:${"a".repeat(64)}`, - onAct, -}: { - sessions: TestSessionPort; - routingStrategy?: WorkHubRoutingStrategy; - transcript?: readonly WorkHubCoordinationTurn[]; - candidateSetId?: string; - onAct?: (input: WorkHubCoordinationActInput) => void; -}) { - let candidateByRef = new Map(); - return createGatedWorkHubController({ - sessions, - ...(routingStrategy ? { routingStrategy } : {}), - coordination: { - open: async (handler) => { handler(transcript); return { close: async () => undefined }; }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => { - const candidates = (await sessions.list()) - .filter((entry) => entry.kind === 'ordinary' && !entry.archived) - .map((entry) => ({ - candidateRef: `candidate-${entry.target.sessionId}`, - sessionId: entry.target.sessionId, - sessionName: entry.sessionName, - workspace: { - target: { kind: 'host_path' as const, path: `/workspace/${entry.target.sessionId}` }, - hostCwd: `/workspace/${entry.target.sessionId}`, - }, - state: entry.state, - updatedAt: entry.updatedAt, - })); - const byId = new Map( - (await sessions.list()).map((entry) => [entry.target.sessionId, entry]), - ); - candidateByRef = new Map(candidates.flatMap((candidate) => { - const entry = byId.get(candidate.sessionId); - return entry ? [[candidate.candidateRef, entry] as const] : []; - })); - return { - candidateSetId, - candidates, - }; - }, - act: async (input) => { - onAct?.(input); - if (input.proposal.disposition === 'answer_here') { - return { - disposition: 'answer_here', - coordinationTurnId: input.actionId, - }; - } - if (input.proposal.disposition === 'clarify') { - return { - disposition: 'clarify', - coordinationTurnId: input.actionId, - }; - } - if (input.proposal.disposition === 'create_new') { - const created = await sessions.create({ name: input.proposal.title }); - const admitted = await sessions.submit(created.target, input.userText, input.actionId); - return { - disposition: 'create_new', - targetSessionId: created.target.sessionId, - targetTurnId: admitted.turnId, - ...(admitted.steered ? { steered: true as const } : {}), - }; - } - if (input.proposal.disposition === 'replace') { - if (input.proposal.target.disposition === 'create_new') { - const created = await sessions.create({ name: input.proposal.target.title }); - const admitted = await sessions.submit(created.target, input.userText, input.actionId); - return { - disposition: 'replace', - replacementDisposition: 'create_new', - targetSessionId: created.target.sessionId, - targetTurnId: admitted.turnId, - ...(admitted.steered ? { steered: true as const } : {}), - }; - } - const replacementTarget = candidateByRef.get(input.proposal.target.candidateRef); - if (!replacementTarget) throw new Error('unknown test replacement candidate'); - const admitted = await sessions.submit( - replacementTarget.target, - input.userText, - input.actionId, - ); - return { - disposition: 'replace', - replacementDisposition: 'delegate_existing', - targetSessionId: replacementTarget.target.sessionId, - targetTurnId: admitted.turnId, - ...(admitted.steered ? { steered: true as const } : {}), - }; - } - if (input.proposal.disposition === 'stop_work') { - return { - disposition: 'stop_work', - outcome: 'cancelled_pending', - targetSessionId: input.proposal.expects.targetSessionId, - }; - } - if (input.proposal.disposition === 'resume_work') { - return { - disposition: 'resume_work', - outcome: 'resume_started', - targetSessionId: input.proposal.expects.targetSessionId, - targetTurnId: 'resumed-turn', - }; - } - const target = candidateByRef.get(input.proposal.candidateRef); - if (!target) throw new Error('unknown test candidate'); - const admitted = await sessions.submit(target.target, input.userText, input.actionId); - return { - disposition: 'delegate_existing', - targetSessionId: target.target.sessionId, - targetTurnId: admitted.turnId, - ...(admitted.steered ? { steered: true as const } : {}), - }; - }, - }, - }); -} - - -/** Repeatable comparison through the real controller; only Host execution is stubbed. */ -export async function runRoutingComparison(input: { - repetitions: number; - sessions: readonly WorkHubSessionFacts[]; - transcript: readonly WorkHubCoordinationTurn[]; - candidateSetId: string; - cases: readonly { caseId: string; text: string }[]; - model: WorkHubRoutingModelPort; -}) { - if (!Number.isSafeInteger(input.repetitions) || input.repetitions < 1) { - throw new Error('repetitions must be a positive integer'); - } - const observations: Array<{ repetition: number; caseId: string; result: WorkHubSubmission; proposals: WorkHubCoordinationActInput[] }> = []; - for (let repetition = 0; repetition < input.repetitions; repetition += 1) { - for (const routingStrategy of [createWorkHubR24RoutingStrategy(), createWorkHubR3ARoutingStrategy({ model: input.model }), createWorkHubR3BRoutingStrategy({ model: input.model })]) { - const facts = structuredClone([...input.sessions]); - const sessions = port(facts); - sessions.create = async ({ name }) => { - const created = session(`created-${facts.length}`, { sessionName: name }); - facts.push(created); - return created; - }; - const proposals: WorkHubCoordinationActInput[] = []; - const controller = createWorkHubController({ sessions, routingStrategy, - transcript: structuredClone([...input.transcript]), candidateSetId: input.candidateSetId, - onAct: (proposal) => proposals.push(proposal), - }); - const conversation = await controller.openConversation(() => {}, (error) => { throw error; }); - try { - for (const entry of input.cases) { - const start = proposals.length; - const result = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: `${repetition}:${routingStrategy.strategyId}:${entry.caseId}`, text: entry.text }); - observations.push({ repetition, caseId: entry.caseId, result, proposals: proposals.slice(start) }); - } - } finally { await conversation.close(); } - } - } - return observations; -} diff --git a/apps/desktop/src/main/__tests__/workhub-controller.test.ts b/apps/desktop/src/main/__tests__/workhub-controller.test.ts deleted file mode 100644 index 98b68f833d..0000000000 --- a/apps/desktop/src/main/__tests__/workhub-controller.test.ts +++ /dev/null @@ -1,3364 +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 assert from 'node:assert/strict'; -import { createWorkHubController, port, session } from './workhub-controller-fixture.js'; -import { existsSync, readFileSync } from 'node:fs'; -import test from 'node:test'; -import type { WorkHubCoordinationActInput } from '@maka/runtime-host/protocol'; -import { - createWorkHubController as createGatedWorkHubController, - WORKHUB_ROUTING_STRATEGY_ID, - WorkHubCoordinationFailure, - type WorkHubSessionFacts, - type WorkHubSessionPort, - type WorkHubCoordinationTurn, -} from '../../renderer/workhub-controller.js'; -import { - createWorkHubRoutePolicy, - workHubNewSessionName as workHubNewSessionNameForLocale, -} from '../../renderer/features/workhub/index.js'; -import { - createWorkHubR24RoutingStrategy, - createWorkHubR3BRoutingStrategy, - createWorkHubR3ARoutingStrategy, - WORKHUB_R3A_ROUTING_STRATEGY_ID, - type WorkHubRoutingStrategy, -} from '../../renderer/features/workhub/index.js'; - -const workHubNewSessionName = (text: string) => workHubNewSessionNameForLocale(text, '新工作'); - -const appShellUrl = [ - new URL('../../renderer/app-shell.tsx', import.meta.url), - new URL('../../../src/renderer/app-shell.tsx', import.meta.url), -].find((candidate) => existsSync(candidate)); - -if (!appShellUrl) throw new Error('Could not locate renderer/app-shell.tsx'); - -test('binds the controller to the immutable WH-R2.4 strategy ID', () => { - assert.equal(WORKHUB_ROUTING_STRATEGY_ID, 'wh-r2.4-session-context-continuity'); -}); - -test('binds the WorkHub controller to one Coordination identity rather than project refreshes', () => { - const source = readFileSync(appShellUrl, 'utf8'); - - assert.doesNotMatch(source, /workHubControllerRef\s*=\s*useRef/u); - assert.match( - source, - /const workHubController\s*=\s*useMemo\([\s\S]*?\[workHubCoordinationGeneration, workHubCoordinationSessionId\],\s*\)/u, - ); - assert.match(source, /workHubProjectsRef\.current\s*=\s*projects/u); - assert.doesNotMatch( - source, - /useMemo\(\(\)\s*=>\s*createWorkHubController\([\s\S]*?\),\s*\[projects\]\)/u, - ); -}); - -function coordinationAssignmentTurn(): WorkHubCoordinationTurn { - return { - messageId: 'assignment-1', - turnId: 'action-1', - text: 'Continue payments', - state: 'completed', - assignment: { - actionId: 'action-1', - delegationId: 'delegation-1', - targetSessionId: 'payment', - targetSessionName: 'Payments', - targetMessageId: 'payment-message', - targetTurnId: 'payment-turn', - feedbackState: 'accepted', - linkState: 'active', - }, - updatedAt: 10, - }; -} - -test('conversation acknowledges a durable assignment before projecting target execution', async () => { - const sessions = port([session('payment')]); - let onSessionChanged: (() => void) | undefined; - let feedbackState: 'completed' | 'waiting_for_user' = 'completed'; - sessions.subscribe = (handler) => { - onSessionChanged = handler; - return () => { - onSessionChanged = undefined; - }; - }; - sessions.delegationFeedback = async (references) => - references.map(({ delegationId }) => ({ delegationId, state: feedbackState })); - const assignment = coordinationAssignmentTurn(); - const snapshots: string[] = []; - const activeSnapshots: string[][] = []; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async (handler) => { - handler([assignment]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ candidateSetId: `sha256:${'a'.repeat(64)}`, candidates: [] }), - act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), - }, - }); - - const handle = await controller.openConversation((turns) => { - snapshots.push(turns[0]?.assignment?.feedbackState ?? 'missing'); - activeSnapshots.push(turns.flatMap((turn) => turn.assignment?.linkState === 'active' ? [turn.assignment.targetSessionId] : [])); - }, () => undefined); - await Promise.resolve(); - - assert.deepEqual(snapshots.slice(0, 2), ['accepted', 'completed']); - assert.deepEqual(activeSnapshots, [['payment'], ['payment']]); - - feedbackState = 'waiting_for_user'; - onSessionChanged?.(); - await Promise.resolve(); - await Promise.resolve(); - assert.equal(snapshots.at(-1), 'waiting_for_user'); - - await handle.close(); -}); - -test('conversation feedback never lets an older refresh overwrite newer target state', async () => { - const sessions = port([session('payment')]); - let onSessionChanged: (() => void) | undefined; - sessions.subscribe = (handler) => { - onSessionChanged = handler; - return () => undefined; - }; - type Feedback = Awaited>; - const pending: Array<{ - references: Parameters[0]; - resolve(feedback: Feedback): void; - }> = []; - sessions.delegationFeedback = (references) => - new Promise((resolve) => pending.push({ references, resolve })); - const snapshots: string[] = []; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async (handler) => { - const assignment = coordinationAssignmentTurn(); - handler([assignment]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ candidateSetId: `sha256:${'b'.repeat(64)}`, candidates: [] }), - act: async () => ({ disposition: 'answer_here', coordinationTurnId: 'unused' }), - }, - }); - - const handle = await controller.openConversation((turns) => { - snapshots.push(turns[0]?.assignment?.feedbackState ?? 'missing'); - }, () => undefined); - assert.equal(pending.length, 1); - onSessionChanged?.(); - assert.equal(pending.length, 2); - - pending[1]!.resolve(pending[1]!.references.map(({ delegationId }) => ({ - delegationId, - state: 'completed', - }))); - await Promise.resolve(); - await Promise.resolve(); - pending[0]!.resolve(pending[0]!.references.map(({ delegationId }) => ({ - delegationId, - state: 'failed', - }))); - await Promise.resolve(); - await Promise.resolve(); - - assert.equal(snapshots.at(-1), 'completed'); - assert.equal(snapshots.includes('failed'), false); - await handle.close(); -}); - -test('direct stop bypasses routing candidates and preserves a not_owned delegation link', async () => { - const sessions = port([session('payments', { sessionName: 'Payments' })]); - const actions: WorkHubCoordinationActInput[] = []; - let candidateReads = 0; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async (handler) => { - handler([coordinationAssignmentTurn()]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => { - candidateReads += 1; - return { candidateSetId: `sha256:${'d'.repeat(64)}`, candidates: [] }; - }, - act: async (input) => { - actions.push(input); - return { - disposition: 'stop_work', - outcome: 'not_owned', - targetSessionId: 'payments', - targetTurnId: 'shared-turn', - }; - }, - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - const result = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'stop-1', text: 'Stop Payments' }); - assert.deepEqual(result, { - kind: 'stop', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'stop-1', - target: { sessionId: 'payments' }, - outcome: 'not_owned', - targetTurnId: 'shared-turn', - }); - // The proposal carries only the Session the reference resolved to. No display - // name and no delegation identity reach the Action Gate: which link to end is - // the Host's to decide. - assert.deepEqual(actions, [{ - actionId: 'stop-1', - userText: 'Stop Payments', - proposal: { - disposition: 'stop_work', - expects: { targetSessionId: 'payments' }, - }, - confirmation: { kind: 'user_stop' }, - }]); - assert.equal(candidateReads, 0); - - const retry = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'stop-2', text: 'Stop Payments' }); - assert.equal(retry.kind, 'stop'); - assert.equal(actions.length, 2); - await handle.close(); -}); - -test('an anaphoric stop asks for a fresh named imperative without offering a route choice', async () => { - const sessions = port([session('payments', { sessionName: 'Payments' })]); - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async (handler) => { - handler([]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => assert.fail('stop clarification must not read route candidates'), - act: async () => assert.fail('anaphoric stop must not reach the Action Gate'), - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - assert.deepEqual(await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'stop-it', text: 'Stop it' }), { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'stop-it', - text: 'Stop it', - options: [], - reason: 'stop_target_required', - }); - await handle.close(); -}); - -test('a named resume submits and reports what the Host did', async () => { - const sessions = port([session('payments', { sessionName: 'Payments' })]); - const actions: WorkHubCoordinationActInput[] = []; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async (handler) => { - handler([]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'e'.repeat(64)}`, - candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], - }), - act: async (input) => { - actions.push(input); - return { - disposition: 'resume_work', - outcome: 'resume_started', - targetSessionId: 'payments', - targetTurnId: 'resumed-turn', - }; - }, - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - const result = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-1', text: 'Resume Payments' }); - - assert.deepEqual(result, { - kind: 'resume', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'resume-1', - target: { sessionId: 'payments' }, - outcome: 'resume_started', - }); - // The proposal names the Session and carries no confirmation: resume ends - // nothing, so it needs no authority a delegation did not already grant. - assert.deepEqual(actions, [{ - actionId: 'resume-1', - userText: 'Resume Payments', - proposal: { disposition: 'resume_work', resumesActionId: 'source-action', expects: { targetSessionId: 'payments' } }, - }]); - await handle.close(); -}); - -test('an anaphoric resume asks for a named work item', async () => { - const controller = createGatedWorkHubController({ - sessions: port([session('payments', { sessionName: 'Payments' })]), - coordination: { - open: async (handler) => { - handler([]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => assert.fail('resume clarification must not read route candidates'), - act: async () => assert.fail('anaphoric resume must not reach the Action Gate'), - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - assert.deepEqual(await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-it', text: 'Resume it' }), { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'resume-it', - text: 'Resume it', - options: [], - reason: 'resume_target_required', - }); - await handle.close(); -}); - -test('a resume the Host will not admit becomes its clarification', async () => { - const sessions = port([session('payments', { sessionName: 'Payments' })]); - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async (handler) => { - handler([]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'e'.repeat(64)}`, - candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], - }), - act: async () => { - throw new WorkHubCoordinationFailure( - 'operation_conflict', - 'WorkHub has no active durable delegation to resume on that Session', - ); - }, - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - assert.deepEqual(await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-2', text: 'Resume Payments' }), { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'resume-2', - text: 'Resume Payments', - options: [], - reason: 'resume_target_unavailable', - }); - await handle.close(); -}); - -test('a resume identity conflict is not mislabeled as a missing target', async () => { - const conflict = new WorkHubCoordinationFailure( - 'operation_conflict', - 'WorkHub action identity already owns a different operation', - ); - const controller = createGatedWorkHubController({ - sessions: port([session('payments', { sessionName: 'Payments' })]), - coordination: { - open: async (handler) => { - handler([]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'e'.repeat(64)}`, - candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], - }), - act: async () => { - throw conflict; - }, - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - await assert.rejects( - controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-conflict', text: 'Resume Payments' }), - (error) => error === conflict, - ); - await handle.close(); -}); - -test('a Runtime Host without safe-boundary resume explains why it cannot resume', async () => { - const controller = createGatedWorkHubController({ - sessions: port([session('payments', { sessionName: 'Payments' })]), - coordination: { - open: async (handler) => { - handler([]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'e'.repeat(64)}`, - candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], - }), - act: async () => { - throw new WorkHubCoordinationFailure( - 'operation_unavailable', - 'Safe-boundary resume is disabled for this Runtime Host', - ); - }, - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - assert.deepEqual(await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-disabled', text: 'Resume Payments' }), { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'resume-disabled', - text: 'Resume Payments', - options: [], - reason: 'resume_operation_unavailable', - }); - await handle.close(); -}); - -test('a recovering Runtime Host tells the user to retry resume', async () => { - const controller = createGatedWorkHubController({ - sessions: port([session('payments', { sessionName: 'Payments' })]), - coordination: { - open: async (handler) => { - handler([]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'e'.repeat(64)}`, - candidates: [{ candidateRef: 'candidate-payments', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }], - }), - act: async () => { - throw new WorkHubCoordinationFailure('host_not_ready', 'Runtime Host is recovering'); - }, - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - const result = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-recovering', text: 'Resume Payments' }); - assert.equal(result.kind, 'clarification'); - if (result.kind === 'clarification') assert.equal(result.reason, 'resume_host_recovering'); - await handle.close(); -}); - -test('a named stop reports the Gate refusal instead of judging the target itself', async () => { - // The renderer no longer decides whether a Session can be stopped, so it - // submits and lets the Gate answer. Its refusal is the clarification, which - // is the only version of this answer that cannot contradict the Host. - const sessions = port([session('payments', { sessionName: 'Payments' })]); - let submitted = 0; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async (handler) => { - handler([]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => assert.fail('stop clarification must not read route candidates'), - act: async () => { - submitted += 1; - throw new WorkHubCoordinationFailure( - 'operation_conflict', - 'WorkHub has no active durable delegation to stop on that Session', - ); - }, - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - assert.deepEqual(await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'stop-payments', text: 'Stop Payments' }), { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'stop-payments', - text: 'Stop Payments', - options: [], - reason: 'stop_target_unavailable', - }); - assert.equal(submitted, 1, 'the Host is the one that decides, so it must be asked'); - await handle.close(); -}); - -test('a stop that fails for any other reason is a fault, not a clarification', async () => { - const sessions = port([session('payments', { sessionName: 'Payments' })]); - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async (handler) => { - handler([]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => assert.fail('stop clarification must not read route candidates'), - act: async () => { - throw new WorkHubCoordinationFailure('persistence_failed', 'WorkHub stop state is unavailable'); - }, - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - await assert.rejects( - () => controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'stop-payments', text: 'Stop Payments' }), - /WorkHub stop state is unavailable/, - ); - await handle.close(); -}); - -test('stop-shaped ordinary work routes normally instead of looping on clarification', async () => { - for (const [sessionName, text] of [ - ['Payments', 'Stop using the deprecated API in Payments'], - ['支付任务', '停止使用支付任务里的旧接口'], - ] as const) { - const sessions = port([session('payments', { sessionName })]); - const actions: WorkHubCoordinationActInput[] = []; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async (handler) => { - handler([]); - return { close: async () => undefined }; - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'e'.repeat(64)}`, - candidates: [{ - candidateRef: 'candidate-payments', - sessionId: 'payments', - sessionName, - workspace: { - target: { kind: 'host_path' as const, path: '/workspace/payments' }, - hostCwd: '/workspace/payments', - }, - state: 'active' as const, - updatedAt: 1, - }], - }), - act: async (input) => { - actions.push(input); - return { - disposition: 'delegate_existing', - targetSessionId: 'payments', - targetTurnId: 'payments-turn', - }; - }, - }, - }); - const handle = await controller.openConversation(() => undefined, () => undefined); - - const result = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: `work-${sessionName}`, text }); - assert.equal(result.kind, 'submitted', text); - assert.deepEqual( - actions.map((action) => action.proposal.disposition), - ['delegate_existing'], - text, - ); - await handle.close(); - } -}); - -test('read exposes existing ordinary Sessions as factual Work summaries', async () => { - const controller = createWorkHubController({ - sessions: port([ - session('login', { - sessionName: '登录刷新令牌', - state: 'running', - latestResult: '已定位到刷新竞争条件', - updatedAt: 30, - }), - session('payment', { - projectName: 'billing', - sessionName: '支付回调幂等性', - archived: true, - latestResult: '处理支付回调重复投递', - updatedAt: 20, - }), - session('hub-internal', { kind: 'internal', updatedAt: 50 }), - session('child-agent', { kind: 'subagent', updatedAt: 40 }), - ]), - }); - - const projection = await controller.read(); - - assert.deepEqual(projection.sessions, [ - { - target: { sessionId: 'login' }, - projectName: 'maka', - sessionName: '登录刷新令牌', - archived: false, - state: 'running', - latestResult: '已定位到刷新竞争条件', - updatedAt: 30, - }, - { - target: { sessionId: 'payment' }, - projectName: 'billing', - sessionName: '支付回调幂等性', - archived: true, - state: 'active', - latestResult: '处理支付回调重复投递', - updatedAt: 20, - }, - ]); - assert.deepEqual(projection.turns, []); -}); - -test('read does not rebuild WorkHub conversation from ordinary Session turns', async () => { - const sessions = port([ - session('login', { sessionName: '登录刷新令牌', updatedAt: 30 }), - session('internal', { kind: 'internal', updatedAt: 40 }), - ]); - const requestedTargets: string[][] = []; - sessions.recentTurns = async (targets) => { - requestedTargets.push(targets.map((target) => target.sessionId)); - return [{ - messageId: 'user-1', - target: { sessionId: 'login' }, - turnId: 'turn-login', - text: '检查刷新令牌竞争条件', - state: 'completed', - result: '已定位到并发刷新窗口', - updatedAt: 20, - }]; - }; - - const projection = await createWorkHubController({ sessions }).read(); - - assert.deepEqual(requestedTargets, []); - assert.deepEqual(projection.turns, []); -}); - -test('archived Sessions stay inspectable but are excluded from routing targets', async () => { - const evidenceTargets: string[][] = []; - const submitted: string[] = []; - const sessions = port([ - session('archived-payment', { - sessionName: '支付回调幂等性', - archived: true, - updatedAt: 30, - }), - session('active-login', { - sessionName: '登录刷新令牌', - updatedAt: 20, - }), - ]); - sessions.routingEvidence = async (targets) => { - evidenceTargets.push(targets.map((target) => target.sessionId)); - return []; - }; - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'unexpected' }; - }; - const controller = createWorkHubController({ sessions }); - - const projection = await controller.read(); - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'archived-target', - text: '支付回调幂等性现在是什么状态?', - }); - - assert.equal(projection.sessions.some((entry) => entry.archived), true); - assert.deepEqual(evidenceTargets, [['active-login']]); - assert.equal(result.kind, 'discussion'); - assert.deepEqual(submitted, []); -}); - -test('submit sends an explicitly targeted request to that Session', async () => { - const submitted: Array<{ sessionId: string; text: string }> = []; - const sessions = port([session('payment', { sessionName: '支付回调幂等性' })]); - sessions.submit = async (target, text) => { - submitted.push({ sessionId: target.sessionId, text }); - return { turnId: 'turn-payment' }; - }; - const controller = createWorkHubController({ sessions }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-1', - text: '补充重复投递测试', - explicitTarget: { sessionId: 'payment' }, - }); - - assert.deepEqual(result, { - kind: 'submitted', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'request-1', - target: { sessionId: 'payment' }, - turnId: 'turn-payment', - evidence: 'explicit_target', - }); - assert.deepEqual(submitted, [ - { sessionId: 'payment', text: '补充重复投递测试' }, - ]); -}); - -test('submit routes a unique complete Session name without asking', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录刷新令牌' }), - session('payment', { projectName: 'billing', sessionName: '支付回调幂等性' }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-exact' }; - }; - const controller = createWorkHubController({ sessions }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-exact', - text: '在支付回调幂等性里补充重复投递测试', - }); - - assert.deepEqual(result, { - kind: 'submitted', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'request-exact', - target: { sessionId: 'payment' }, - turnId: 'turn-exact', - evidence: 'exact_session_name', - }); - assert.deepEqual(submitted, ['payment']); - assert.equal((await controller.read()).focusSessionId, 'payment'); -}); - -test('an injected R3 strategy still delegates through the shared controller and coordination.act port', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录刷新令牌' }), - session('payment', { sessionName: '支付回调幂等性' }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-model-payment' }; - }; - const routingStrategy = createWorkHubR3ARoutingStrategy({ - model: { - decide: async (input) => input.stage === 'intent' - ? { intent: 'work' } - : { kind: 'ranked', candidateRefs: ['candidate-payment'] }, - }, - }); - const controller = createWorkHubController({ sessions, routingStrategy }); - - const result = await controller.submit({ - newSessionFallbackTitle: 'New work', - requestId: 'request-r3-a', - text: '请实现账本边界检查器', - }); - - assert.deepEqual(result, { - kind: 'submitted', - strategyId: WORKHUB_R3A_ROUTING_STRATEGY_ID, - requestId: 'request-r3-a', - target: { sessionId: 'payment' }, - turnId: 'turn-model-payment', - evidence: 'model_candidate', - }); - assert.deepEqual(submitted, ['payment']); -}); - -test('a unique longer Session name outranks a generic contained Session name', async () => { - const submitted: string[] = []; - const sessions = port([ - session('layout', { sessionName: '优化WorkHub移动端消息布局' }), - session('generic', { sessionName: 'WorkHub' }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-layout' }; - }; - - const result = await createWorkHubController({ sessions }).submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-layout', - text: '优化WorkHub移动端消息布局:补充横屏注意点。', - }); - - assert.equal(result.kind, 'submitted'); - assert.deepEqual(submitted, ['layout']); -}); - -test('a short Latin Session name does not match inside another word', async () => { - const submitted: string[] = []; - const created: string[] = []; - const sessions = port([ - session('ai', { sessionName: 'AI' }), - ]); - sessions.create = async ({ name }) => { - created.push(name); - return session('parser-new', { sessionName: name }); - }; - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-parser' }; - }; - - const result = await createWorkHubController({ sessions }).submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-parser', - text: '修复 repair parser 的错误', - }); - - assert.equal(result.kind, 'submitted'); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); - assert.deepEqual(created, ['修复 repair parser 的错误']); - assert.deepEqual(submitted, ['parser-new']); -}); - -test('a one-character Latin discriminator prevents routing to a different Session name', async () => { - for (const { existingName, requestedName } of [ - { existingName: 'GPT-4', requestedName: 'GPT-3' }, - { existingName: 'Project A', requestedName: 'Project B' }, - ]) { - const submitted: string[] = []; - const sessions = port([ - session('existing', { sessionName: existingName }), - ]); - sessions.create = async ({ name }) => session('new', { sessionName: name }); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-new' }; - }; - - const result = await createWorkHubController({ sessions }).submit({ - newSessionFallbackTitle: '新工作', - requestId: `request-${requestedName}`, - text: `请处理 ${requestedName} 的问题`, - }); - - assert.equal(result.kind, 'submitted'); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); - assert.deepEqual(submitted, ['new']); - } -}); - -test('submit asks the user when weak relevance matches more than one Session', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { - sessionName: '登录刷新令牌', - latestResult: '处理刷新令牌过期造成的重复登录', - updatedAt: 20, - }), - session('payment', { - projectName: 'billing', - sessionName: '支付回调幂等性', - latestResult: '处理支付回调重复投递', - updatedAt: 30, - }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'unexpected' }; - }; - const controller = createWorkHubController({ sessions }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-ambiguous', - text: '继续处理重复问题', - }); - - assert.deepEqual(result, { - kind: 'clarification', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'request-ambiguous', - text: '继续处理重复问题', - options: [ - { - target: { sessionId: 'payment' }, - projectName: 'billing', - sessionName: '支付回调幂等性', - }, - { - target: { sessionId: 'login' }, - projectName: 'maka', - sessionName: '登录刷新令牌', - }, - ], - }); - assert.deepEqual(submitted, []); -}); - -test('submit keeps origin prompts as stable evidence after latest results change', async () => { - const sessions = port([ - session('login', { - sessionName: '登录刷新令牌', - latestResult: '已经整理为检查清单', - updatedAt: 20, - }), - session('payment', { - sessionName: '支付回调幂等性', - latestResult: '已经把风险按高、中、低分组', - updatedAt: 30, - }), - ]); - sessions.routingEvidence = async () => [ - { - target: { sessionId: 'login' }, - originPrompt: '排查刷新令牌过期导致的重复登录', - }, - { - target: { sessionId: 'payment' }, - originPrompt: '检查支付回调重复投递时的幂等性', - }, - ]; - sessions.submit = async () => ({ turnId: 'turn-focus-login' }); - const controller = createWorkHubController({ sessions }); - await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-focus-login', - text: '先看登录', - explicitTarget: { sessionId: 'login' }, - }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-origin-ambiguity', - text: '继续处理重复问题', - }); - - assert.equal(result.kind, 'clarification'); - assert.deepEqual(result.kind === 'clarification' - ? result.options.map((option) => option.target.sessionId) - : [], ['payment', 'login']); -}); - -test('submit creates a new executable topic instead of following one weak old clue', async () => { - const createdNames: string[] = []; - const sessions = port([ - session('login', { - sessionName: '登录刷新令牌', - latestResult: '已经整理为检查清单', - }), - ]); - sessions.routingEvidence = async () => [{ - target: { sessionId: 'login' }, - originPrompt: '排查刷新令牌过期导致的重复登录', - }]; - sessions.create = async ({ name }) => { - createdNames.push(name); - return session('payment-new', { sessionName: name }); - }; - sessions.submit = async () => ({ turnId: 'turn-payment-new' }); - const controller = createWorkHubController({ sessions }); - const text = '检查支付回调重复投递时的幂等性,先只分析风险和测试点,不修改文件。'; - - const result = await controller.submit({ newSessionFallbackTitle: '新工作', requestId: 'request-payment-new', text }); - - assert.equal(result.kind, 'submitted'); - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'payment-new', - }); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); - assert.deepEqual(createdNames, ['检查支付回调重复投递时的幂等性']); -}); - -test('submit does not treat a project name as strong topic evidence', async () => { - const createdNames: string[] = []; - const sessions = port([ - session('login', { - projectName: 'maka-workhub-session-router', - sessionName: '登录刷新令牌', - }), - ]); - sessions.routingEvidence = async () => [{ - target: { sessionId: 'login' }, - originPrompt: '排查刷新令牌过期导致的重复登录', - }]; - sessions.create = async ({ name }) => { - createdNames.push(name); - return session('layout-new', { sessionName: name }); - }; - sessions.submit = async () => ({ turnId: 'turn-layout-new' }); - const controller = createWorkHubController({ sessions }); - const text = '优化 WorkHub 在移动端窄屏下的消息布局,先给设计建议,不修改文件。'; - - const result = await controller.submit({ newSessionFallbackTitle: '新工作', requestId: 'request-layout-new', text }); - - assert.equal(result.kind, 'submitted'); - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'layout-new', - }); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); - assert.deepEqual(createdNames, ['优化 WorkHub 在移动端窄屏下的消息布局']); -}); - -test('submit follows an unambiguous reference to the most recent Work', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录刷新令牌' }), - session('payment', { sessionName: '支付回调幂等性' }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - const controller = createWorkHubController({ sessions }); - await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-focus', - text: '先处理支付', - explicitTarget: { sessionId: 'payment' }, - }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-pronoun', - text: '继续它', - }); - - assert.deepEqual(result, { - kind: 'submitted', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'request-pronoun', - target: { sessionId: 'payment' }, - turnId: 'turn-2', - evidence: 'recent_focus', - }); - assert.deepEqual(submitted, ['payment', 'payment']); -}); - -test('read seeds current and previous focus from pre-existing ordinary Sessions', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录刷新令牌', updatedAt: 20 }), - session('payment', { sessionName: '支付回调幂等性', updatedAt: 30 }), - session('archived', { archived: true, updatedAt: 40 }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - const controller = createWorkHubController({ sessions }); - - await controller.read(); - const current = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-current-seed', - text: '继续这个工作', - }); - const previous = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-previous-seed', - text: '回到上一个工作', - }); - - assert.deepEqual(current.kind === 'submitted' ? current.target : undefined, { - sessionId: 'payment', - }); - assert.deepEqual(previous.kind === 'submitted' ? previous.target : undefined, { - sessionId: 'login', - }); - assert.deepEqual(submitted, ['payment', 'login']); -}); - -test('read prefers the Session active when WorkHub opens over raw recency', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录刷新令牌', updatedAt: 20 }), - session('payment', { sessionName: '支付回调幂等性', updatedAt: 30 }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-login' }; - }; - const controller = createWorkHubController({ sessions }); - - await controller.read({ focus: { sessionId: 'login' } }); - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-active-seed', - text: '继续这个工作', - }); - - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'login', - }); - assert.deepEqual(submitted, ['login']); -}); - -test('a stale opening read cannot overwrite a newer WorkHub focus', async () => { - const pendingReads: Array<{ - resolve(value: WorkHubSessionFacts[]): void; - promise: Promise; - }> = []; - const sessions = port([]); - sessions.list = () => { - let resolve!: (value: WorkHubSessionFacts[]) => void; - const promise = new Promise((next) => { - resolve = next; - }); - pendingReads.push({ resolve, promise }); - return promise; - }; - const submitted: string[] = []; - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-newer-focus' }; - }; - const controller = createWorkHubController({ sessions }); - const older = controller.read({ focus: { sessionId: 'payment' } }); - const newer = controller.read({ focus: { sessionId: 'login' } }); - const facts = [ - session('login', { updatedAt: 20 }), - session('payment', { updatedAt: 30 }), - ]; - - pendingReads[1]!.resolve(facts); - await newer; - pendingReads[0]!.resolve([facts[1]!]); - await older; - sessions.list = async () => facts; - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-after-stale-read', - text: '继续这个工作', - }); - - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'login', - }); - assert.deepEqual(submitted, ['login']); -}); - -test('an unavailable opening focus falls back to recent routable Sessions', async () => { - const submitted: string[] = []; - const sessions = port([ - session('archived', { archived: true, updatedAt: 40 }), - session('login', { updatedAt: 20 }), - session('payment', { updatedAt: 30 }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-fallback' }; - }; - const controller = createWorkHubController({ sessions }); - - await controller.read({ focus: { sessionId: 'archived' } }); - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-fallback-focus', - text: '继续这个工作', - }); - - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'payment', - }); - assert.deepEqual(submitted, ['payment']); -}); - -test('focus falls back when the current Session is archived after WorkHub opens', async () => { - let catalog = [ - session('login', { updatedAt: 20 }), - session('payment', { updatedAt: 30 }), - ]; - const sessions = port(catalog); - sessions.list = async () => catalog; - const submitted: string[] = []; - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-focus-fallback' }; - }; - const controller = createWorkHubController({ sessions }); - await controller.read(); - catalog = catalog.map((entry) => entry.target.sessionId === 'payment' - ? { ...entry, archived: true } - : entry); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-after-current-archive', - text: '继续这个工作', - }); - - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'login', - }); - assert.deepEqual(submitted, ['login']); -}); - -test('resetVisitContext discards focus from a previous WorkHub mount', async () => { - let catalog = [ - session('login', { updatedAt: 20 }), - session('payment', { updatedAt: 30 }), - ]; - const sessions = port(catalog); - sessions.list = async () => catalog; - const submitted: string[] = []; - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - const controller = createWorkHubController({ sessions }); - await controller.read({ focus: { sessionId: 'login' } }); - controller.resetVisitContext(); - catalog = catalog.map((entry) => entry.target.sessionId === 'payment' - ? { ...entry, updatedAt: 40 } - : entry); - await controller.read(); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-after-remount', - text: '继续这个工作', - }); - - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'payment', - }); -}); - -test('an in-flight submit cannot restore visit focus after WorkHub unmounts', async () => { - const sessions = port([ - session('login', { updatedAt: 20 }), - session('payment', { updatedAt: 30 }), - ]); - let signalSubmitStarted!: () => void; - const submitStarted = new Promise((resolve) => { - signalSubmitStarted = resolve; - }); - let finishSubmit!: (value: { turnId: string }) => void; - const pendingTurn = new Promise<{ turnId: string }>((resolve) => { - finishSubmit = resolve; - }); - sessions.submit = async () => { - signalSubmitStarted(); - return pendingTurn; - }; - const controller = createWorkHubController({ sessions }); - await controller.read({ focus: { sessionId: 'login' } }); - const inFlight = controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-before-unmount', - text: '继续这个工作', - }); - await submitStarted; - controller.resetVisitContext(); - finishSubmit({ turnId: 'turn-login' }); - await inFlight; - - const submitted: string[] = []; - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-after-remount' }; - }; - await controller.read(); - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-after-in-flight', - text: '继续这个工作', - }); - - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'payment', - }); - assert.deepEqual(submitted, ['payment']); -}); - -test('an old submit resolves against the visit focus captured before an await', async () => { - const catalog = [ - session('login', { updatedAt: 20 }), - session('payment', { updatedAt: 30 }), - ]; - const sessions = port(catalog); - const controller = createWorkHubController({ sessions }); - await controller.read({ focus: { sessionId: 'login' } }); - - let signalListStarted!: () => void; - const listStarted = new Promise((resolve) => { - signalListStarted = resolve; - }); - let finishOldList!: (value: WorkHubSessionFacts[]) => void; - const oldList = new Promise((resolve) => { - finishOldList = resolve; - }); - let blockNextList = true; - sessions.list = async () => { - if (!blockNextList) return catalog; - blockNextList = false; - signalListStarted(); - return oldList; - }; - const submitted: string[] = []; - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-' + target.sessionId }; - }; - - const oldSubmission = controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-old-visit', - text: '继续这个工作', - }); - await listStarted; - controller.resetVisitContext(); - await controller.read({ focus: { sessionId: 'payment' } }); - finishOldList(catalog); - - const result = await oldSubmission; - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'login', - }); - assert.deepEqual(submitted, ['login']); -}); - -test('submit routes strong core evidence instead of reusing recent focus', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { - sessionName: '登录稳定性', - latestResult: '处理刷新令牌重复登录', - }), - session('payment', { - sessionName: '支付稳定性', - latestResult: '处理支付回调重复投递', - }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - const controller = createWorkHubController({ sessions }); - await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-login-focus', - text: '先看登录', - explicitTarget: { sessionId: 'login' }, - }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-topic-shift', - text: '继续处理支付回调重复投递', - }); - - assert.deepEqual(result, { - kind: 'submitted', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'request-topic-shift', - target: { sessionId: 'payment' }, - turnId: 'turn-2', - evidence: 'core_entity', - }); - assert.deepEqual(submitted, ['login', 'payment']); -}); - -test('submit routes unique strong core evidence without asking', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { - sessionName: '登录稳定性', - latestResult: '处理刷新令牌过期导致的重复登录', - }), - session('payment', { - sessionName: '支付稳定性', - latestResult: '处理支付回调重复投递', - }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-core' }; - }; - const controller = createWorkHubController({ sessions }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-core', - text: '刷新令牌过期时,重复登录的观测日志应该记录哪些字段?', - }); - - assert.equal(result.kind, 'submitted'); - if (result.kind !== 'submitted') return; - assert.deepEqual(result.target, { sessionId: 'login' }); - assert.equal(result.evidence, 'core_entity'); - assert.equal(result.strategyId, 'wh-r2.4-session-context-continuity'); - assert.deepEqual(submitted, ['login']); -}); - -test('submit ignores shared boilerplate when an executable request names a new topic', async () => { - const createdNames: string[] = []; - const sessions = port([ - session('login', { - sessionName: '登录刷新令牌', - latestResult: '排查登录刷新令牌,先只分析风险和测试点,不修改文件', - }), - ]); - sessions.create = async ({ name }) => { - createdNames.push(name); - return session('payment-new', { sessionName: name }); - }; - sessions.submit = async () => ({ turnId: 'turn-payment-new' }); - const controller = createWorkHubController({ sessions }); - const text = '请创建新任务,检查支付回调重复投递;先只分析风险和测试点,不修改文件。'; - - const result = await controller.submit({ newSessionFallbackTitle: '新工作', requestId: 'request-new-topic', text }); - - assert.equal(result.kind, 'submitted'); - if (result.kind !== 'submitted') return; - assert.deepEqual(result.target, { sessionId: 'payment-new' }); - assert.equal(result.evidence, 'new_session'); - assert.equal(result.strategyId, 'wh-r2.4-session-context-continuity'); - assert.deepEqual(createdNames, ['检查支付回调重复投递']); -}); - -test('submit keeps a foreign two-character clue behind clarification', async () => { - const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 10 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 20 }), - ]); - const controller = createWorkHubController({ sessions }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-weak', - text: '继续登录', - }); - - assert.equal(result.kind, 'clarification'); - assert.equal(result.strategyId, 'wh-r2.4-session-context-continuity'); -}); - -test('submit treats explicit user uncertainty as clarification instead of a new Session', async () => { - const created: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性', updatedAt: 20 }), - session('payment', { sessionName: '支付稳定性', updatedAt: 30 }), - ]); - sessions.create = async ({ name }) => { - created.push(name); - return session('unexpected'); - }; - const controller = createWorkHubController({ sessions }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-uncertain', - text: '继续处理稳定性问题,但我不确定具体是哪一个。', - }); - - assert.equal(result.kind, 'clarification'); - assert.deepEqual(result.kind === 'clarification' - ? result.options.map((option) => option.target.sessionId) - : [], ['payment', 'login']); - assert.deepEqual(created, []); -}); - -test('English target uncertainty uses clarification as the routing safety valve', async () => { - const submitted: string[] = []; - const sessions = port([ - session('parser', { sessionName: 'Parser Cleanup', updatedAt: 20 }), - session('profile', { sessionName: 'Profile Settings', updatedAt: 30 }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'unexpected' }; - }; - - const result = await createWorkHubController({ sessions }).submit({ - newSessionFallbackTitle: '新工作', - requestId: 'english-uncertainty', - text: "I'm not sure which one this belongs to; continue the cleanup.", - }); - - assert.equal(result.kind, 'clarification'); - assert.deepEqual(result.kind === 'clarification' - ? result.options.map((option) => option.target.sessionId) - : [], ['parser', 'profile']); - assert.deepEqual(submitted, []); -}); - -test('English routing matches whole words instead of substrings in another identity', async () => { - const submitted: string[] = []; - const created: string[] = []; - const sessions = port([ - session('profile', { sessionName: 'Profile Settings' }), - ]); - sessions.create = async ({ name }) => { - created.push(name); - return session('parser-new', { sessionName: name }); - }; - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-parser' }; - }; - - const result = await createWorkHubController({ sessions }).submit({ - newSessionFallbackTitle: '新工作', - requestId: 'english-word-boundary', - text: 'check the file parser', - }); - - assert.equal(result.kind, 'submitted'); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); - assert.deepEqual(created, ['check the file parser']); - assert.deepEqual(submitted, ['parser-new']); -}); - -test('English core evidence requires a distinctive word or multiple whole-word matches', async () => { - const submitted: string[] = []; - const sessions = port([ - session('parser', { - sessionName: 'Parser Cleanup', - latestResult: 'Tokenizer regression isolated in parser recovery', - }), - session('profile', { - sessionName: 'Profile Settings', - latestResult: 'Account preferences are ready', - }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: 'turn-parser' }; - }; - - const result = await createWorkHubController({ sessions }).submit({ - newSessionFallbackTitle: '新工作', - requestId: 'english-core-evidence', - text: 'fix the parser tokenizer crash', - }); - - assert.equal(result.kind, 'submitted'); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'core_entity'); - assert.deepEqual(submitted, ['parser']); -}); - -test('waiting Session rejects a second root request without calling submit', async () => { - let submitted = false; - const sessions = port([ - session('login', { - sessionName: '排查令牌过期重复登录问题', - state: 'waiting_for_user', - }), - ]); - sessions.submit = async () => { - submitted = true; - return { turnId: 'unexpected' }; - }; - const controller = createWorkHubController({ sessions }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-waiting', - text: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', - }); - - assert.deepEqual(result, { - kind: 'waiting', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'request-waiting', - text: '排查令牌过期重复登录问题:补充一条等待状态下的新请求。', - target: { sessionId: 'login' }, - }); - assert.equal(submitted, false); -}); - -test('submit returns to the previous focused Session', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录刷新令牌' }), - session('payment', { sessionName: '支付回调幂等性' }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - const controller = createWorkHubController({ sessions }); - await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-login', - text: '先看登录', - explicitTarget: { sessionId: 'login' }, - }); - await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-payment', - text: '再看支付', - explicitTarget: { sessionId: 'payment' }, - }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-previous', - text: '回到上一个工作', - }); - - assert.equal(result.kind, 'submitted'); - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { sessionId: 'login' }); - assert.deepEqual(submitted, ['login', 'payment', 'login']); -}); - -test('submit lets strong foreign core evidence override a vague focus word', async () => { - const submitted: string[] = []; - const sessions = port([ - session('login', { - sessionName: '登录稳定性', - latestResult: '处理刷新令牌过期导致的重复登录', - }), - session('payment', { - sessionName: '支付稳定性', - latestResult: '处理支付回调重复投递', - }), - ]); - sessions.submit = async (target) => { - submitted.push(target.sessionId); - return { turnId: `turn-${submitted.length}` }; - }; - const controller = createWorkHubController({ sessions }); - await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-payment-focus', - text: '先看支付', - explicitTarget: { sessionId: 'payment' }, - }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-foreign-core', - text: '继续处理刷新令牌过期', - }); - - assert.equal(result.kind, 'submitted'); - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { sessionId: 'login' }); - assert.deepEqual(submitted, ['payment', 'login']); -}); - -test('submit keeps unmatched non-executable conversation in WorkHub', async () => { - let created = false; - const actions: unknown[] = []; - const sessions = port([]); - sessions.create = async () => { - created = true; - return session('unexpected'); - }; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'a'.repeat(64)}`, - candidates: [], - }), - act: async (input) => { - actions.push(input); - return { - disposition: 'answer_here', - coordinationTurnId: 'coordination-turn', - }; - }, - }, - }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-discussion', - text: '你觉得统一入口最重要的价值是什么?', - }); - - assert.deepEqual(result, { - kind: 'discussion', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'request-discussion', - text: '你觉得统一入口最重要的价值是什么?', - }); - assert.equal(created, false); - assert.deepEqual(actions, [ - { - actionId: 'request-discussion', - userText: '你觉得统一入口最重要的价值是什么?', - proposal: { disposition: 'answer_here' }, - }, - ]); -}); - -test('production submission delegates only through the Runtime-owned candidate reference', async () => { - const actions: unknown[] = []; - const sessions = port([session('payment')]); - sessions.submit = async () => { - throw new Error('renderer direct submit must not be used'); - }; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'b'.repeat(64)}`, - candidates: [{ - candidateRef: 'candidate-payment', - sessionId: 'payment', - sessionName: 'payment', - workspace: { - target: { kind: 'host_path', path: '/workspace/payment' }, - hostCwd: '/workspace/payment', - }, - state: 'active', - updatedAt: 1, - }], - }), - act: async (input) => { - actions.push(input); - return { - disposition: 'delegate_existing', - targetSessionId: 'payment', - targetTurnId: 'target-turn', - }; - }, - }, - }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'delegate-action', - text: '继续支付工作', - explicitTarget: { sessionId: 'payment' }, - }); - - assert.equal(result.kind, 'submitted'); - assert.equal(result.kind === 'submitted' ? result.turnId : undefined, 'target-turn'); - assert.deepEqual(actions, [{ - actionId: 'delegate-action', - userText: '继续支付工作', - candidateSetId: `sha256:${'b'.repeat(64)}`, - proposal: { - disposition: 'delegate_existing', - candidateRef: 'candidate-payment', - }, - }]); -}); - -test('production retry reaches durable Action Gate replay while target is waiting', async () => { - const actions: unknown[] = []; - const sessions = port([ - session('payment', { state: 'waiting_for_user' }), - ]); - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'c'.repeat(64)}`, - candidates: [{ - candidateRef: 'candidate-payment', - sessionId: 'payment', - sessionName: 'payment', - workspace: { - target: { kind: 'host_path', path: '/workspace/payment' }, - hostCwd: '/workspace/payment', - }, - state: 'waiting_for_user', - updatedAt: 2, - }], - }), - act: async (input) => { - actions.push(input); - return { - disposition: 'delegate_existing', - targetSessionId: 'payment', - targetTurnId: 'already-committed-turn', - }; - }, - }, - }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'summary-recovery-action', - text: '继续支付工作', - explicitTarget: { sessionId: 'payment' }, - retryAction: true, - }); - - assert.equal(result.kind, 'submitted'); - assert.equal(result.kind === 'submitted' ? result.turnId : undefined, 'already-committed-turn'); - assert.equal(actions.length, 1); -}); - -test('production sends an explicit correction as a linked replacement', async () => { - const actions: unknown[] = []; - const sessions = port([session('source'), session('target')]); - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'d'.repeat(64)}`, - candidates: [ - { - candidateRef: 'candidate-source', - sessionId: 'source', - sessionName: 'source', - workspace: { - target: { kind: 'host_path', path: '/workspace/source' }, - hostCwd: '/workspace/source', - }, - state: 'active', - updatedAt: 1, - }, - { - candidateRef: 'candidate-target', - sessionId: 'target', - sessionName: 'target', - workspace: { - target: { kind: 'host_path', path: '/workspace/target' }, - hostCwd: '/workspace/target', - }, - state: 'active', - updatedAt: 2, - }, - ], - }), - act: async (input) => { - actions.push(input); - return { - disposition: 'replace', - replacementDisposition: 'delegate_existing', - targetSessionId: 'target', - targetTurnId: 'replacement-turn', - }; - }, - }, - }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'linked-correction', - text: 'No, use target instead', - explicitTarget: { sessionId: 'target' }, - correction: { from: { sessionId: 'source' }, sourceActionId: 'source-action' }, - }); - - assert.equal(result.kind, 'submitted'); - assert.deepEqual(actions, [ - { - actionId: 'linked-correction', - userText: 'No, use target instead', - candidateSetId: `sha256:${'d'.repeat(64)}`, - confirmation: { kind: 'user_correction' }, - proposal: { - disposition: 'replace', - replacesActionId: 'source-action', - target: { - disposition: 'delegate_existing', - candidateRef: 'candidate-target', - }, - }, - }, - ]); -}); - -const PRODUCTION_CORRECTION_CREATION_CASES = [ - ['production-correction-with-create-en', 'No, create a new session called Login instead'], - [ - 'production-correction-with-polite-create-en', - 'No, please create a new session called Login instead', - ], - [ - 'production-correction-with-em-dash-en', - 'No — create a new session called Login instead', - ], - ['production-correction-with-create-zh', '不是这个,创建一个新会话叫登录稳定性'], - ['production-correction-with-polite-create-zh', '不是这个,请创建一个新会话叫Login'], - ['production-correction-with-alternate-cue-zh', '不对,创建一个新会话叫Login'], -] as const; - -test('production natural-language corrections retain the prior delegation link', async () => { - const actions: WorkHubCoordinationActInput[] = []; - const sessions = port([ - session('login', { - sessionName: '登录稳定性', - latestResult: '刷新令牌过期导致重复登录', - updatedAt: 20, - }), - session('payment', { - sessionName: '支付稳定性', - latestResult: '支付回调重复投递', - updatedAt: 30, - }), - ]); - const candidateSetId = `sha256:${'e'.repeat(64)}`; - const latestActionIdBySessionId = new Map(); - const candidates = [ - { - candidateRef: 'candidate-login', - sessionId: 'login', - sessionName: '登录稳定性', - workspace: { - target: { kind: 'host_path' as const, path: '/workspace/login' }, - hostCwd: '/workspace/login', - }, - state: 'active' as const, - updatedAt: 20, - }, - { - candidateRef: 'candidate-payment', - sessionId: 'payment', - sessionName: '支付稳定性', - workspace: { - target: { kind: 'host_path' as const, path: '/workspace/payment' }, - hostCwd: '/workspace/payment', - }, - state: 'active' as const, - updatedAt: 30, - }, - ]; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId, - candidates: candidates.map((candidate) => { - const latestDelegationActionId = latestActionIdBySessionId.get(candidate.sessionId); - return latestDelegationActionId - ? { ...candidate, latestDelegationActionId } - : candidate; - }), - }), - act: async (input) => { - actions.push(input); - if (input.proposal.disposition === 'replace') { - if (input.proposal.target.disposition === 'create_new') { - return { - disposition: 'replace', - replacementDisposition: 'create_new', - targetSessionId: `created-${input.actionId}`, - targetTurnId: `turn-${input.actionId}`, - }; - } - latestActionIdBySessionId.set( - input.proposal.target.candidateRef === 'candidate-login' ? 'login' : 'payment', - input.actionId, - ); - return { - disposition: 'replace', - replacementDisposition: 'delegate_existing', - targetSessionId: input.proposal.target.candidateRef === 'candidate-login' - ? 'login' - : 'payment', - targetTurnId: 'runtime-login-turn', - }; - } - if (input.proposal.disposition !== 'delegate_existing') { - throw new Error('unexpected test disposition'); - } - latestActionIdBySessionId.set( - input.proposal.candidateRef === 'candidate-login' ? 'login' : 'payment', - input.actionId, - ); - return { - disposition: 'delegate_existing', - targetSessionId: input.proposal.candidateRef === 'candidate-login' - ? 'login' - : 'payment', - targetTurnId: input.actionId === 'production-wrong-payment' - ? 'runtime-payment-turn' - : 'runtime-login-turn', - }; - }, - }, - }); - await controller.read(); - await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'production-wrong-payment', - text: '继续这个工作,补充验收项', - }); - - const corrected = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'production-natural-correction', - text: '不是这个,换成登录稳定性,补充刷新令牌失败判定', - }); - assert.equal(corrected.kind, 'submitted'); - - const [creationRequestId, creationText] = PRODUCTION_CORRECTION_CREATION_CASES[0]; - assert.equal( - (await controller.submit({ newSessionFallbackTitle: '新工作', requestId: creationRequestId, text: creationText })).kind, - 'submitted', - ); - - assert.equal(actions.length, 3); - assert.deepEqual(actions[1], { - actionId: 'production-natural-correction', - userText: '不是这个,换成登录稳定性,补充刷新令牌失败判定', - candidateSetId, - confirmation: { kind: 'user_correction' }, - proposal: { - disposition: 'replace', - replacesActionId: 'production-wrong-payment', - target: { - disposition: 'delegate_existing', - candidateRef: 'candidate-login', - }, - }, - }); - assert.deepEqual( - actions.slice(2).map((action) => action.proposal.disposition), - ['replace'], - ); -}); - -test('production correction-shaped creation stays create_new without an existing focus', async () => { - const dispositions: string[] = []; - const controller = createGatedWorkHubController({ - sessions: port([]), - coordination: { - open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'c'.repeat(64)}`, - candidates: [], - }), - act: async (input) => { - dispositions.push(input.proposal.disposition); - return { - disposition: 'create_new', - targetSessionId: `runtime-created-${dispositions.length}`, - targetTurnId: `runtime-turn-${dispositions.length}`, - }; - }, - }, - }); - - for (const [requestId, text] of PRODUCTION_CORRECTION_CREATION_CASES) { - const result = await controller.submit({ newSessionFallbackTitle: '新工作', requestId: `without-focus-${requestId}`, text }); - assert.equal(result.kind, 'submitted'); - } - - assert.deepEqual(dispositions, Array(PRODUCTION_CORRECTION_CREATION_CASES.length) - .fill('create_new')); -}); - -test('production clarification is persisted through the typed Action Gate disposition', async () => { - const actions: unknown[] = []; - const controller = createGatedWorkHubController({ - sessions: port([]), - coordination: { - open: async () => ({ close: async () => undefined }), - record: async () => { - throw new Error('legacy summary recording must not persist clarification'); - }, - candidates: async () => ({ - candidateSetId: `sha256:${'c'.repeat(64)}`, - candidates: [], - }), - act: async (input) => { - actions.push(input); - return { - disposition: 'clarify', - coordinationTurnId: 'clarification-turn', - }; - }, - }, - }); - - assert.deepEqual(await controller.recordConversationTurn({ - turnId: 'clarification-action', - userText: '继续稳定性问题', - assistantText: '请选择目标 Session', - disposition: 'clarify', - }), { turnId: 'clarification-turn' }); - assert.deepEqual(actions, [{ - actionId: 'clarification-action', - userText: '继续稳定性问题', - proposal: { - disposition: 'clarify', - assistantText: '请选择目标 Session', - }, - }]); -}); - -test('production creation leaves Session identity and workspace authority to main and Runtime', async () => { - const actions: unknown[] = []; - const sessions = port([]); - sessions.create = async () => { - throw new Error('renderer direct create must not be used'); - }; - const controller = createGatedWorkHubController({ - sessions, - coordination: { - open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ - candidateSetId: `sha256:${'c'.repeat(64)}`, - candidates: [], - }), - act: async (input) => { - actions.push(input); - return { - disposition: 'create_new', - targetSessionId: 'runtime-created', - targetTurnId: 'runtime-turn', - }; - }, - }, - }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'create-action', - text: '请创建新任务,检查支付回调重复投递。', - }); - - assert.equal(result.kind, 'submitted'); - assert.deepEqual(result.kind === 'submitted' ? result.target : undefined, { - sessionId: 'runtime-created', - }); - assert.deepEqual(actions, [{ - actionId: 'create-action', - userText: '请创建新任务,检查支付回调重复投递。', - proposal: { - disposition: 'create_new', - title: '检查支付回调重复投递', - }, - }]); -}); - -test('submit treats a design question containing an action word as discussion', async () => { - let created = false; - const sessions = port([]); - sessions.create = async () => { - created = true; - return session('unexpected'); - }; - const controller = createWorkHubController({ sessions }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-design-question', - text: '我们应该怎么实现统一入口?', - }); - - assert.equal(result.kind, 'discussion'); - assert.equal(created, false); -}); - -test('an executable English request may contain what without becoming discussion', async () => { - const created: string[] = []; - const sessions = port([]); - sessions.create = async ({ name }) => { - created.push(name); - return session('parser-fix', { sessionName: name }); - }; - sessions.submit = async () => ({ turnId: 'turn-parser-fix' }); - - const result = await createWorkHubController({ sessions }).submit({ - newSessionFallbackTitle: '新工作', - requestId: 'english-what-object', - text: 'fix what is broken in the parser', - }); - - assert.equal(result.kind, 'submitted'); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); - assert.deepEqual(created, ['fix what is broken in the parser']); -}); - -test('submit creates an ordinary Session for a clear unmatched executable goal', async () => { - const createdNames: string[] = []; - const submitted: Array<{ sessionId: string; text: string }> = []; - const sessions = port([]); - sessions.create = async ({ name }) => { - createdNames.push(name); - return session('invoice-export', { sessionName: name }); - }; - sessions.submit = async (target, text) => { - submitted.push({ sessionId: target.sessionId, text }); - return { turnId: 'turn-invoice-export' }; - }; - const controller = createWorkHubController({ sessions }); - - const result = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-new-work', - text: '实现导出发票 PDF 功能', - }); - - assert.deepEqual(result, { - kind: 'submitted', - strategyId: WORKHUB_ROUTING_STRATEGY_ID, - requestId: 'request-new-work', - target: { sessionId: 'invoice-export' }, - turnId: 'turn-invoice-export', - evidence: 'new_session', - }); - assert.deepEqual(createdNames, ['实现导出发票 PDF 功能']); - assert.deepEqual(submitted, [ - { sessionId: 'invoice-export', text: '实现导出发票 PDF 功能' }, - ]); -}); - -test('explicit new-Session intent outranks generic evidence from existing work', async () => { - const created: string[] = []; - const sessions = port([ - session('login', { sessionName: '登录稳定性测试计划' }), - session('payment', { sessionName: '支付回调测试计划' }), - ]); - sessions.create = async ({ name }) => { - created.push(name); - return session('new-session', { sessionName: name }); - }; - sessions.submit = async () => ({ turnId: 'turn-new-session' }); - - const result = await createWorkHubController({ sessions }).submit({ - newSessionFallbackTitle: '新工作', - requestId: 'request-explicit-new', - text: '创建一个全新的普通 Session,标题为 R2.3 新建工作验收,只记录测试计划。', - }); - - assert.equal(result.kind, 'submitted'); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); - assert.deepEqual(created, ['R2.3 新建工作验收']); -}); - -test('English explicit creation extracts the requested Session name', async () => { - const created: string[] = []; - const sessions = port([]); - sessions.create = async ({ name }) => { - created.push(name); - return session('parser-cleanup', { sessionName: name }); - }; - sessions.submit = async () => ({ turnId: 'turn-parser-cleanup' }); - - const result = await createWorkHubController({ sessions }).submit({ - newSessionFallbackTitle: '新工作', - requestId: 'english-explicit-new', - text: 'Create a new session called Parser Cleanup.', - }); - - assert.equal(result.kind, 'submitted'); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); - assert.deepEqual(created, ['Parser Cleanup']); -}); - -test('Chinese explicit creation strips Session naming introducers', () => { - assert.deepEqual( - [ - '创建一个新的 Session,名为登录稳定性', - '新建工作叫支付回调幂等性', - '开一个任务命名为消息恢复', - '不对,请创建一个新的 Session 标题为登录稳定性', - '错了,新建一个会话名称为支付任务', - '不对,不要创建一个新会话叫登录;而是创建一个新会话叫支付。', - ].map((text) => workHubNewSessionName(text)), - ['登录稳定性', '支付回调幂等性', '消息恢复', '登录稳定性', '支付任务', '支付'], - ); - assert.equal( - workHubNewSessionName( - 'No, create a new Session called Payments, and add documentation containing the example new Session called Fraud.', - ), - 'Payments', - ); - assert.equal(workHubNewSessionName('Create a new Session called U.S. Payments'), 'U.S. Payments'); - assert.equal(workHubNewSessionName('Create a new Session called Dr. Login'), 'Dr. Login'); - assert.equal( - workHubNewSessionName('Create a new Session called Acme Inc. Payments'), - 'Acme Inc. Payments', - ); - assert.equal(workHubNewSessionName('Create a new Session called No. 5 Login'), 'No. 5 Login'); - assert.equal( - workHubNewSessionName('Create a new Session called Ph.D. Research'), - 'Ph.D. Research', - ); - assert.equal(workHubNewSessionName('Create a new Session called App. Fix login'), 'App'); - assert.equal(workHubNewSessionName('Create a new Session called Fix. Add documentation.'), 'Fix'); - assert.equal(workHubNewSessionName('Create a new Session called Go. Then add tests.'), 'Go'); - assert.equal( - workHubNewSessionName('Create a new Session called Acme Inc. Fix login.'), - 'Acme Inc', - ); - assert.equal(workHubNewSessionName('Create a new Session called U.S. Fix login.'), 'U.S'); - assert.equal(workHubNewSessionName('Create a new Session called Ph.D. Fix login.'), 'Ph.D'); - assert.equal(workHubNewSessionName('Create a new Session called No. Fix login.'), 'No'); - assert.equal(workHubNewSessionName('Create a new Session called St. Fix login.'), 'St'); - assert.equal( - workHubNewSessionName('Create a new Session called Acme Inc. Then fix login.'), - 'Acme Inc', - ); - assert.equal( - workHubNewSessionName('Create a new Session called Acme Inc. Please fix login.'), - 'Acme Inc', - ); - assert.equal( - workHubNewSessionName('Create a new Session called Acme Inc. Please then fix login.'), - 'Acme Inc', - ); - assert.equal( - workHubNewSessionName('Create a new Session called Acme Inc. Then, please fix login.'), - 'Acme Inc', - ); - assert.equal( - workHubNewSessionName('Create a new Session called Acme Inc. Finally fix login.'), - 'Acme Inc', - ); - assert.equal( - workHubNewSessionName('Create a new Session called Acme Inc. Afterwards fix login.'), - 'Acme Inc', - ); - assert.equal(workHubNewSessionName('Create a new Session called U.S. Can you fix login?'), 'U.S'); - assert.equal(workHubNewSessionName('Create a new Session called U.S. Next, fix login.'), 'U.S'); - assert.equal(workHubNewSessionName('Create a new Session called U.S. Also fix login.'), 'U.S'); - assert.equal( - workHubNewSessionName('Create a new Session called U.S. Immediately fix login.'), - 'U.S', - ); - assert.equal( - workHubNewSessionName('Create a new Session called U.S. Proceed to fix login.'), - 'U.S', - ); - assert.equal( - workHubNewSessionName('Create a new Session called U.S. At that point fix login.'), - 'U.S', - ); - assert.equal(workHubNewSessionName('Create a new Session called U.S. Daily Fix'), 'U.S. Daily Fix'); - assert.equal( - workHubNewSessionName('Create a new Session called U.S. Monthly Update'), - 'U.S. Monthly Update', - ); - assert.equal( - workHubNewSessionName('Create a new Session called U.S. Monthly update'), - 'U.S. Monthly update', - ); - assert.equal( - workHubNewSessionName('Create a new Session called U.S. customer update'), - 'U.S. customer update', - ); - assert.equal( - workHubNewSessionName('Create a new Session called Ph.D. Could you fix login?'), - 'Ph.D', - ); - assert.equal(workHubNewSessionName('Create a new Session called Ph.D. Now fix login.'), 'Ph.D'); - assert.equal( - workHubNewSessionName('Create a new Session called Ph.D. Finally fix login.'), - 'Ph.D', - ); - assert.equal(workHubNewSessionName('Create a new Session called Ph.D. 接下来修复登录。'), 'Ph.D'); - assert.equal(workHubNewSessionName('Create a new Session called Ph.D. 最后修复登录。'), 'Ph.D'); - assert.equal( - workHubNewSessionName('Create a new Session called Ph.D. Friendly Fix'), - 'Ph.D. Friendly Fix', - ); - assert.equal( - workHubNewSessionName('Create a new Session called Ph.D. Friendly fix'), - 'Ph.D. Friendly fix', - ); -}); - -test('English routing boilerplate does not make an old analysis look related', async () => { - const created: string[] = []; - const sessions = port([ - session('login', { - sessionName: 'Login Refresh Token', - latestResult: 'Just analyze the risks and test cases; do not modify any files.', - }), - ]); - sessions.create = async ({ name }) => { - created.push(name); - return session('payment-new', { sessionName: name }); - }; - sessions.submit = async () => ({ turnId: 'turn-payment-new' }); - - const result = await createWorkHubController({ sessions }).submit({ - newSessionFallbackTitle: '新工作', - requestId: 'english-boilerplate', - text: "Check payment callback duplicate delivery; just analyze the risks and test cases; don't modify any files.", - }); - - assert.equal(result.kind, 'submitted'); - assert.equal(result.kind === 'submitted' ? result.evidence : undefined, 'new_session'); - assert.deepEqual(created, ['Check payment callback duplicate delivery']); -}); - -test('negated and deliberative creation language never creates a Session', async () => { - const created: string[] = []; - const sessions = port([]); - sessions.create = async ({ name }) => { - created.push(name); - return session('unexpected', { sessionName: name }); - }; - const controller = createWorkHubController({ sessions }); - - const negated = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'negated-create', - text: '不要创建一个新任务,我们先讨论这个方向。', - }); - const deliberative = await controller.submit({ - newSessionFallbackTitle: '新工作', - requestId: 'question-create', - text: '是否应该新建一个任务?', - }); - - assert.equal(negated.kind, 'discussion'); - assert.equal(deliberative.kind, 'discussion'); - assert.deepEqual(created, []); -}); - -test('polite executable questions and file-level constraints still create new work', () => { - const cases = [ - 'Can you fix login stability?', - 'Can you please fix login?', - 'Could you implement payment retry?', - 'Could you kindly implement payment retry?', - 'If retries fail, can you fix login?', - 'If retries fail can you fix login?', - 'If retries fail then can you fix login?', - 'When retries fail, can you fix login?', - 'If retries fail then fix login?', - 'When retries fail, fix login?', - 'When retries fail fix login?', - '如果重试失败就请修复登录?', - 'Fix login, but leave documentation unchanged', - 'Fix login, but hold API behavior constant', - 'Fix login, but wait for tests before merging', - 'Create a new Session called App. Fix login', - 'Create a new Session called Fix. Add documentation.', - 'Create a new Session called Go. Then add tests.', - 'Create a new Session called Acme Inc. Fix login.', - 'Create a new Session called U.S. Fix login.', - 'Create a new Session called Ph.D. Fix login.', - 'Create a new Session called No. Fix login.', - 'Create a new Session called St. Fix login.', - 'Create a new Session called Acme Inc. Then fix login.', - 'Create a new Session called Acme Inc. Please fix login.', - 'Create a new Session called U.S. Can you fix login?', - 'Create a new Session called Ph.D. Could you fix login?', - 'Explain the issue, then fix login', - 'Tell me the cause and fix login', - 'Tell me the options and fix login', - 'Recommend options and fix login', - 'Can you fix login, but leave documentation unchanged?', - 'Please try to reproduce and fix login', - 'Try to reproduce and fix login', - 'Work to diagnose and fix login', - 'Explain that issue and fix login', - 'Update the label to How can I help?', - 'Fix copy to say What should I do?', - 'Implement an FAQ answering How can I recover?', - 'Update the prompt to How can I help?', - 'Fix the heading to What should I do?', - 'Update the tooltip to Where can I find files?', - 'Update the message to Why did this fail?', - 'Investigate and fix login', - 'Analyze and fix login', - 'Debug and fix login', - 'Review and update docs', - 'First investigate, then fix login', - 'Assess and fix login', - 'Examine and fix login', - '调查并修复登录', - '先分析,然后修复登录', - 'Investigate the issue and fix both login and logout.', - 'Review the failure and fix the affected user accounts.', - 'Analyze the suite and update the generated docs.', - '先分析,然后修复已经失败的测试。', - 'Investigate and fix login stability.', - 'Review and update API docs.', - 'Analyze and fix payment retry logic.', - 'Audit and update generated API docs.', - 'Investigate issue and fix login for mobile.', - 'Assess logs and update docs for operators.', - 'Review issue and fix login in production.', - 'Discuss the approach, then implement retry', - 'Consider the options, but fix login now', - '请修复支付回调重复投递?', - 'Fix how login errors are reported', - 'Update how retries are calculated', - '请修复用户不知道怎么登录的问题', - '请实现如何恢复失败任务的逻辑', - 'Create a new Session to fix how login errors are reported', - 'Implement docs to explain how retries work', - 'Update the guide to discuss why login fails', - '修复帮助页以解释如何恢复失败任务', - 'Fix login stability, but do not create any files', - '修复登录稳定性,但不要创建任何文件', - 'Create a new Session for login, but do not create files', - 'If retries fail, fix login', - 'If tests fail, fix login.', - 'If needed fix login.', - 'If necessary implement retries.', - 'When ready fix login.', - 'If possible fix login.', - 'If required fix login.', - 'If safe fix login.', - 'If appropriate implement retries.', - 'When convenient update docs.', - 'When available fix login.', - 'When feasible fix login.', - 'If desired fix login.', - 'If applicable fix login.', - 'When practical update docs.', - 'If advisable implement retries.', - 'If permitted fix login.', - 'When complete update docs.', - 'If urgent fix login.', - 'When sensible implement retries.', - 'If needed, implement payment retry', - ]; - for (const text of cases) { - assert.equal( - createWorkHubRoutePolicy().resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [], - originPromptBySessionId: new Map(), - }).kind, - 'new_session', - text, - ); - } -}); - -test('advisory how-to ambiguity asks for a direct instruction', () => { - for (const text of [ - 'Explain how to fix login, then update the docs.', - 'Tell me how to diagnose login, and fix the bug.', - '解释如何修复登录,然后更新文档。', - 'Explain how to diagnose login; then fix it.', - 'Explain how to diagnose and reproduce login, then fix it.', - 'Explain how to diagnose the text "do not fix", then update docs.', - 'Explain how to diagnose the text `do not fix`, then update docs.', - 'Explain how to diagnose the text (do not fix), then update docs.', - 'Show me how to diagnose login, then fix it.', - 'Walk me through how to diagnose login, then fix it.', - '教我如何诊断登录,然后修复它。', - ]) { - assert.deepEqual( - createWorkHubRoutePolicy().resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [], - originPromptBySessionId: new Map(), - }), - { kind: 'clarification', options: [], reason: 'ambiguous_command' }, - text, - ); - } -}); - -test('advisory ambiguity overrides explicit, exact-name, and recent-focus routing', () => { - const login = session('login', { sessionName: 'Login' }); - const text = 'Explain how to diagnose Login, then fix it.'; - const expected = { kind: 'clarification', options: [], reason: 'ambiguous_command' }; - - assert.deepEqual( - createWorkHubRoutePolicy().resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [login], - originPromptBySessionId: new Map(), - explicitTarget: login.target, - }), - expected, - ); - assert.deepEqual( - createWorkHubRoutePolicy().resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [login], - originPromptBySessionId: new Map(), - }), - expected, - ); - - const focusedPolicy = createWorkHubRoutePolicy(); - focusedPolicy.rememberTarget(login.target); - assert.deepEqual( - focusedPolicy.resolve({ - newSessionFallbackTitle: '新工作', - text: 'Explain how to diagnose this, then fix it.', - sessions: [login], - originPromptBySessionId: new Map(), - }), - expected, - ); -}); - -test('literal negator targets still create new work', () => { - for (const text of [ - "Create a new Session for parsing don't", - "Fix parsing of don't", - 'Update the button label to do not', - '修改按钮文案为不要了', - "Create a new Session for parsing contractions, e.g. don't", - 'Fix parsing examples, i.e. do not', - "Create a new Session for parsing contractions, e.g., don't", - 'Fix parsing examples, i.e., do not', - 'Update the button label to:\ndo not', - "Fix parser support for this token:\ndon't", - "Fix parser for these literals:\ndon't\ndo not", - '修改按钮文案为:\n不要了', - "Create a new Session for parsing this token:\ndon't", - "Create a new Session to test cases\n1. don't", - "Fix parser for cases\n1. don't", - "Create a new Session to test this code\n don't", - "Fix parser for this code\n\tdon't", - "Create a new Session to test list items\n- don't", - "Fix parser for list items\n- don't", - "Update parser examples:\n- do\n- don't", - "Update parser examples:\n1. do\n2. don't", - "Update parser examples:\n do\n don't", - "Create a new Session for parser examples:\n- do\n- don't", - "Create a new Session to test parser\n*Examples:*\n- don't", - "Create a new Session to test parser\n_Examples:_\n- don't", - 'Create a new Session to update copy\n帮我修改按钮文案为:\n不要了', - '请帮我修改按钮文案为:\n不要了', - "Fix parser support for foo-don't", - "Create a new Session for parsing foo-don't", - ]) { - assert.equal( - createWorkHubRoutePolicy().resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [], - originPromptBySessionId: new Map(), - }).kind, - 'new_session', - text, - ); - } -}); - -test('withdrawing the requested action keeps the input in WorkHub', () => { - for (const text of [ - "Fix login stability, actually don't fix it", - "Fix login stability — actually, don't", - '修复登录稳定性,还是别了', - "Fix login, logout, etc. Don't.", - "Fix login\nDon't.", - '修复登录稳定性\n还是别了', - "Fix login stability - don't", - '修复登录稳定性 - 还是别了', - "Fix parser tokens:\ndon't\n\nactually, don't", - "Fix login\nCorrection:\ndon't", - '修复登录\n更正:\n还是别了', - "Fix login\nWait:\ndon't", - '修复登录\n不对:\n还是别了', - "Fix login\nCorrection note:\n- don't", - '修复登录\n想了想:\n 还是别了', - "Fix login\nIn that case:\ndon't", - "Fix login\nFor example:\ndon't", - "Fix login\nIn this test case:\ndon't", - "Fix login\nParser in this case:\ndon't", - "Fix login\nWith this config value:\ndon't", - "Create a new Session for login\nConfig in this case:\ndon't", - "Create a new Session for login\nTesting, for example:\ndon't", - "Fix login stability, but don't fix it", - "Fix login stability, but don't do that", - "Fix login stability, but don't implement it", - "Implement login stability, but don't fix it", - "Fix login stability, actually don't fix login stability", - 'Fix login stability, but do not fix login stability', - 'Fix login stability and do not fix login stability', - "Fix login stability then don't fix login stability", - 'Fix login stability and please do not fix login stability', - "Fix login stability then kindly don't fix login stability", - 'Fix login stability and could you please not fix login stability', - '修复登录稳定性,不过不要修复它', - '修复登录稳定性,但不要修改它', - '修复登录稳定性,不过不要修复登录稳定性', - '修复登录稳定性并且不要修复登录稳定性', - '修复登录稳定性然后请不要修复登录稳定性', - '修复登录稳定性然后麻烦你不要修复登录稳定性', - '修复登录稳定性然后真的不要修复登录稳定性', - 'Fix login stability and just do not fix login stability', - "Fix login stability and simply don't fix login stability", - '修复登录稳定性然后千万不要修复登录稳定性', - 'Do not create a new Session to fix login stability', - '不要创建一个新的 Session 来修复登录稳定性', - 'Do not create a new Session. Fix login stability', - ]) { - assert.equal( - createWorkHubRoutePolicy().resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [], - originPromptBySessionId: new Map(), - }).kind, - 'discussion', - text, - ); - } -}); - -test('a later affirmative clause creates work after withdrawing an earlier action', () => { - for (const text of [ - "Fix login, but don't do that; instead implement payment retry", - '修复登录,但不要这样做;而是实现支付重试', - "Fix login, but don't do that. Implement payment retry", - "Create a new Session for login, but don't do that; instead implement payment retry", - '创建一个新的 Session 处理登录,不过不要这样做;而是实现支付重试', - 'Fix login and do not fix login documentation', - 'Fix checkout, but do not fix checkout tests', - '修复登录,但不要修复登录文档', - 'Update API documentation, but do not update API', - 'Fix checkout tests, but do not fix checkout', - '修复登录文档,但不要修复登录', - ]) { - assert.equal( - createWorkHubRoutePolicy().resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [], - originPromptBySessionId: new Map(), - }).kind, - 'new_session', - text, - ); - } -}); - -test('a correction with a negated creation tail never proposes a new Session', () => { - const login = session('login', { sessionName: 'Login 登录稳定性', updatedAt: 20 }); - const payment = session('payment', { sessionName: 'Payment 支付稳定性', updatedAt: 30 }); - const cases = [ - '不是这个,换成登录稳定性,不要创建新会话', - 'Wrong session; switch to Login; do not create a new session', - 'Wrong session; switch to Login without creating a new session', - '不是这个,而是不要真的创建一个新的 Session', - 'Wrong session; do not actually create a new Session', - '不是这个,而是不要在没有我确认的情况下创建一个新的 Session', - 'Wrong session; do not under any circumstances whatsoever ever create a new session', - 'Wrong session; create a note and do not ever create a new session', - '不是这个,而是请勿创建一个新的 Session', - '我不想创建一个新的 Session', - '我不打算创建一个新的 Session', - '我不是要创建一个新的 Session,只是讨论', - '我并非要创建一个新的 Session,只是讨论', - '不是想创建一个新的 Session,只是问问', - '我不是让你创建一个新的 Session,只是讨论', - '我不是说要创建一个新的 Session,只是讨论', - '并非让你创建一个新的 Session,只是讨论', - '我没让你创建一个新的 Session,只是讨论', - '我没有让你创建一个新的 Session,只是讨论', - '我不希望你创建一个新的 Session,只是讨论', - '我不是请你创建一个新的 Session,只是讨论', - '我没说要创建一个新的 Session,只是讨论', - '我没有说要创建一个新的 Session,只是讨论', - '我没有打算创建一个新的 Session,只是讨论', - '我没准备创建一个新的 Session,只是讨论', - 'Please refuse to create a new Session', - 'I decline to create a new Session; just discuss', - '我未打算创建一个新的 Session,只是讨论', - 'I will not create a new session', - 'not create a new session; just discuss', - 'Under no circumstances create a new session', - 'Create a new Session; do not create a new Session', - '创建一个新的 Session;不要创建一个新的 Session', - "Create a new Session, but don't create it", - "Create a new Session for login — actually, don't", - '创建一个新的 Session 处理登录,还是别了', - "Create a new Session to fix login, logout, etc. Don't.", - "Create a new Session for login\nDon't.", - "Create a new Session for login - don't", - "Create a new Session for parser tokens:\ndon't\n\nactually, don't", - "Create a new Session for login\nCorrection:\ndon't", - "Create a new Session for login\nFinal correction:\ndon't", - "Create a new Session for login\nCorrection:\n- don't", - "Create a new Session for login\nCorrection:\n1. don't", - "Create a new Session for login\nCorrection:\n don't", - '创建一个新的 Session 处理登录\n更正:\n- 还是别了', - "Create a new Session for login\nCorrection note:\n- don't", - "Create a new Session for login\nOn second thought:\n1. don't", - '创建一个新的 Session 处理登录\n想了想:\n 还是别了', - "Create a new Session for login\n## Correction:\n- don't", - "Create a new Session for login\n**Correction:**\n- don't", - '创建一个新的 Session 处理登录\n## 更正:\n- 还是别了', - "Create a new Session for login\nChange to:\n- don't", - "Create a new Session for login\nUpdate to:\ndon't", - "Create a new Session for login\nCorrection to:\n1. don't", - '创建一个新的 Session 处理登录\n改为:\n- 还是别了', - "Create a new Session for login\nIn any case:\ndon't", - "Create a new Session for parser examples:\n- do\n \n- don't", - "Create a new Session for parser examples:\n1. do\n\t\n2. don't", - "Create a new Session for login\nFor this parser case:\ndon't", - "Create a new Session, but don't create one after all", - '创建一个新的 Session,不过不要创建它', - '不是这个,而是创建一个新的 Session;不要创建一个新的 Session', - 'Wrong session; don’t ever create a new session', - 'Wrong session; do not, under any circumstances, create a new session', - '不是这个,而是创建一个新的 Session;不过不要这样做', - 'No examples create a new Session.', - 'This note says no, create a new Session called Payments', - "No, create a new Session for login but don't", - 'No, please explain how to create a new Session', - 'No, tell me how to create a new Session', - '不对,请解释如何创建一个新的 Session', - '错了,请告诉我怎么创建一个新的 Session', - ]; - - for (const text of cases) { - const policy = createWorkHubRoutePolicy(); - policy.rememberTarget(payment.target); - const decision = policy.resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [login, payment], - originPromptBySessionId: new Map(), - }); - - assert.notEqual(decision.kind, 'new_session', text); - } -}); - -test('a pronoun correction uses the shared affirmative target span', () => { - const source = session('source', { sessionName: 'Source', updatedAt: 30 }); - const payments = session('payments', { sessionName: 'Payments', updatedAt: 20 }); - const policy = createWorkHubRoutePolicy(); - policy.rememberTarget(source.target); - - assert.deepEqual( - policy.resolve({ - newSessionFallbackTitle: '新工作', - text: 'Not this session; move it to Payments', - sessions: [source, payments], - originPromptBySessionId: new Map(), - }), - { - kind: 'target', - target: payments.target, - evidence: 'route_correction', - correctedFrom: source.target, - }, - ); -}); - -test('correction routing preserves quoted and punctuated Session identities', () => { - const source = session('source', { sessionName: 'Source', updatedAt: 30 }); - for (const [text, target] of [ - ['No, use "Payments"', session('payments', { sessionName: 'Payments', updatedAt: 20 })], - [ - 'No, use Research and Development', - session('research', { sessionName: 'Research and Development', updatedAt: 20 }), - ], - [ - 'No, use Payments, Retry', - session('payment-retry', { sessionName: 'Payments, Retry', updatedAt: 20 }), - ], - ['不是这个,换成“支付任务”', session('payment', { sessionName: '支付任务', updatedAt: 20 })], - ] as const) { - const policy = createWorkHubRoutePolicy(); - policy.rememberTarget(source.target); - const decision = policy.resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [source, target], - originPromptBySessionId: new Map(), - }); - assert.equal(decision.kind, 'target', text); - assert.equal(decision.kind === 'target' ? decision.target.sessionId : undefined, target.target.sessionId); - assert.equal(decision.kind === 'target' ? decision.evidence : undefined, 'route_correction'); - } -}); - -test('a negated existing-target correction never proposes destructive replacement', () => { - const login = session('login', { sessionName: 'Login 登录稳定性', updatedAt: 20 }); - const payment = session('payment', { sessionName: 'Payment 支付任务', updatedAt: 30 }); - for (const text of [ - "Not this session; don't move it to Login", - '不是这个会话,但不要转到登录稳定性', - "Not this session; move to Login, but I don't want to move anymore", - '不是这个会话,转到登录稳定性,不过我不想转了', - 'No examples use Login.', - 'This note says no, use Login', - "No, use Login but don't", - "No, use Login and actually don't", - "No, use Login and don't want to move it", - "No, use Login and don't proceed", - "No, use Login and don't go ahead with that", - 'No, use Login, forget it', - 'No, use Login, on second thought leave it', - '不是这个会话,转到登录稳定性然后不想转了', - '不是这个会话,转到登录稳定性然后不要继续', - '不是这个会话,转到登录稳定性,当我没说', - '不是这个会话,转到登录稳定性,还是维持原样', - 'No, use Login, fix payments. Forget it', - 'No, use Login — on second thought leave it', - '不是这个会话,转到登录稳定性,修复支付。当我没说', - ]) { - const policy = createWorkHubRoutePolicy(); - policy.rememberTarget(payment.target); - const decision = policy.resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [login, payment], - originPromptBySessionId: new Map(), - }); - assert.notEqual(decision.kind, 'new_session', text); - if (decision.kind === 'target') { - assert.equal(decision.target.sessionId, payment.target.sessionId, text); - assert.notEqual(decision.evidence, 'route_correction', text); - assert.equal(decision.correctedFrom, undefined, text); - } - } -}); - -test('indirect questions containing action words stay in WorkHub', () => { - for (const text of [ - '我想知道如何修复登录问题', - '我们应该如何修复登录问题', - 'Please explain how to fix login', - 'Tell me how to fix login', - 'I would like to understand how to fix login', - '麻烦解释如何修复登录问题', - '请告诉我如何修复登录问题', - 'Show me how to fix login', - 'Could you walk me through how to fix login', - 'What steps should I take to fix login', - '教我怎么修复登录问题', - '给我讲讲如何修复登录问题', - 'Can you tell me if we should fix login?', - 'Could you evaluate if we should implement payment retry?', - '请告诉我该不该修复登录问题?', - '请告诉我应不应该实现支付重试?', - 'Can you give me steps to fix login', - 'Please give me a way to fix login', - 'Could you outline the steps to fix login', - '告诉我修复登录的步骤', - '给我一个修复登录的方法', - 'Can you tell me: should I fix login?', - 'Can you recommend I fix login?', - '请告诉我:我应该修复登录吗?', - '请告诉我应否修复登录?', - 'Can you tell me if I need to fix login', - 'Could you tell me if I must implement retry', - 'I need to know if I need to fix login', - '请告诉我我应否修复登录', - 'Can you fix login, or should we wait?', - 'Can you fix login? Actually, should we?', - 'Can you fix login, or should we wait', - 'Can you fix login. Actually, should we', - '请修复登录,还是应该先等等?', - '请修复登录,还是应该先等等', - 'Can you fix login, or leave it for now?', - '请修复登录,还是等等吧?', - 'Fix login. Maybe we should wait', - 'Fix login. On second thought, maybe wait', - 'Can you tell me if it is necessary to fix login', - 'Explain when to fix login', - 'Tell me in which cases to fix login', - '请告诉我在什么情况下修复登录', - 'Fix login, but maybe we should wait', - 'Fix login; perhaps we should wait', - '请修复登录,不过也许应该等等', - 'Fix login. Actually, I am not sure.', - 'Fix login. Do you think we should?', - "Fix login. On second thought, I'm not sure", - '请修复登录。我不确定。', - 'Can you recommend I fix login', - 'Could you suggest I implement retry', - 'Explain the circumstances in which to fix login', - 'Tell me the best time to fix login', - 'When should I fix login?', - 'When do we fix login?', - '如果什么时候修复登录?', - 'If unsure whether to fix login?', - 'When in doubt, ask whether to fix login?', - 'If it is unclear how to implement retry?', - 'When is it appropriate to fix login?', - '如果适合修复登录?', - 'Fix login. Is that wise?', - 'Fix login, cancel this task.', - 'Fix login, I take that back.', - 'Create a new Session for Payments; cancel the creation.', - 'Create a new Session for Payments. Cancel the new session.', - '创建一个新会话用于支付然后停止创建。', - 'Fix login, cancel this job.', - 'Fix login, withdraw the request.', - 'Fix login, retract that.', - 'Fix login, forget the request.', - 'Create a new Session for Payments; cancel my request.', - 'Create a new Session for Payments; withdraw that request.', - 'Create a new Session for Payments. Revoke that request.', - 'When might we fix login?', - 'When may we fix login?', - 'When will we fix login?', - 'If it makes sense to fix login?', - '如果现在修复登录合适吗?', - 'Fix login. Are we sure?', - 'Fix login. Are you sure?', - 'Fix login. Are they sure?', - 'Fix login. Should we really?', - 'Fix login, cancel the current task.', - 'Fix login, rescind my request.', - 'Fix login, drop this task.', - 'Fix login. Please cancel the task.', - 'Fix login. I want to cancel the task.', - 'Fix login. Could you cancel the task?', - "Fix login. Let's cancel the task.", - 'Fix login. I would prefer to cancel the task.', - 'Fix login. Please do not proceed with the task.', - 'Fix login. I do not wish to proceed.', - 'When should the service fix login?', - 'When will Alice fix login?', - 'When will the patch fix login?', - 'When must the service fix login?', - 'When ought we fix login?', - 'If you think we should fix login?', - 'If you believe we ought to implement retry?', - 'If it is advisable to fix login?', - 'If I wanted you to fix login, what would happen?', - 'If I asked you to fix login, how would you approach it?', - 'If the plan were to fix login, would that be wise?', - 'If I asked you to fix login?', - 'If I wanted you to fix login?', - 'If the plan were to fix login?', - 'If I wanted you to fix login what would happen?', - 'If I asked you to fix login how would you approach it?', - 'If the plan were to fix login would that be wise?', - '如果现在修复登录可以吗?', - '如果现在修复登录可行吗?', - '如果我让你修复登录会怎样?', - 'Fix login. Do you agree?', - 'Fix login. Are you certain?', - 'Fix login. Do you still want that?', - 'Fix login — do you agree?', - 'Fix login: are you sure?', - 'Fix login, okay?', - 'Fix login, sound good?', - 'Fix login, maybe?', - 'Fix login, perhaps?', - 'Fix login, not sure?', - 'Fix login, any concerns?', - '修复登录,没问题吧?', - 'Could you suggest ways to monitor and fix login', - 'Explain techniques that diagnose and fix login errors.', - 'Discuss approaches that prevent and fix login errors.', - 'Explain the steps to diagnose and fix login.', - 'Explain strategies that diagnose and fix login.', - 'Recommend patterns that detect and fix login.', - 'Could you suggest practical options to monitor and fix login', - 'Tell me possible solutions to identify and fix login', - 'Describe techniques that diagnose and fix login.', - 'Analyze strategies that diagnose and fix login.', - 'Explain a process where we diagnose and fix login.', - 'Describe a framework that diagnoses and fix login.', - 'Outline a workflow that detects and fix login.', - 'Summarize a proposal where we diagnose and fix login.', - 'Compare tools that detect and fix login.', - 'I plan to fix login myself.', - 'The team will fix login.', - 'Suppose we fix login.', - 'If we fix login, users will be happier.', - 'When we fix login, users will be happier.', - '如果我们修复登录,用户会更满意。', - 'If the team can fix login, users will be happier.', - 'If Alice can fix login, users will be happier.', - '如果团队能修复登录,用户会更满意。', - 'Should we fix login and then update docs?', - 'Can we diagnose login and then fix it?', - 'What if we fix login and then update docs?', - 'Maybe investigate and fix login.', - 'Perhaps review and update docs.', - 'Potentially debug and fix login.', - 'Our goal is to investigate and fix login.', - 'The requirement is to investigate and fix login.', - 'The service must diagnose and fix login.', - 'Should we diagnose, then fix login?', - 'How should we fix login, then update docs?', - 'Explain how to fix login and then update docs.', - 'Tell me how to diagnose and then fix login.', - 'Can you explain how to diagnose login and then fix it?', - 'Explain whether we should diagnose then fix login.', - 'Discuss whether to diagnose then fix login.', - 'Tell me how we should diagnose then fix login.', - 'Explain how to diagnose, fix, and test login.', - 'Recommend ways to diagnose, fix, and test login.', - 'Explain how to diagnose login, fix it, and update docs.', - 'Explain whether we should diagnose, then fix login.', - 'Explain the workflow: diagnose, then fix login.', - 'Discuss the sequence: diagnose, then fix login.', - 'Review notes and fix status are attached.', - 'Audit results and fix plans are attached.', - 'Research findings and fix recommendations are attached.', - 'Explain how to diagnose login; then fix it. Is that wise?', - 'Explain how to diagnose login, then fix it—but is that wise?', - 'Explain how to diagnose the text "login, then fix it".', - 'Explain how to diagnose a phrase saying "login; then fix it".', - 'Explain how to diagnose login, and test results are attached.', - 'Explain how to diagnose login; then test results are available.', - 'Audit findings and fix recommendations both matter.', - 'Research findings and fix recommendations changed yesterday.', - '分析报告并修复建议已经附上。', - '调查结果并修复建议都很重要。', - 'Explain how to diagnose the text `login, then fix it`.', - 'Explain how to diagnose the sequence (login, then fix it).', - 'Explain how to diagnose login; then fix it, any concerns?', - 'Explain how to diagnose login; then fix it, do you agree?', - 'Audit findings and fix recommendations matter.', - 'Review notes and fix status matters.', - 'Research findings and fix recommendations changed.', - 'Explain how to diagnose login; then test results matter.', - 'Explain how to diagnose login; then test coverage improved.', - 'Explain how to diagnose login; then update metrics increased.', - 'Explain how to diagnose the text "login, then fix it.', - 'Explain how to diagnose the text `login, then fix it.', - 'Explain how to diagnose the sequence (login (primary), then fix it).', - 'Explain how to diagnose the sequence [login, then fix it].', - ]) { - assert.equal( - createWorkHubRoutePolicy().resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [], - originPromptBySessionId: new Map(), - }).kind, - 'discussion', - text, - ); - } -}); - -test('a fuzzy correction target never becomes destructive routing authority', () => { - const source = session('source', { sessionName: 'Source', updatedAt: 30 }); - const paymentCallback = session('payment-callback', { - sessionName: '支付回调', - updatedAt: 20, - }); - const policy = createWorkHubRoutePolicy(); - policy.rememberTarget(source.target); - - const decision = policy.resolve({ - newSessionFallbackTitle: '新工作', - text: '不是这个,换成支付页面', - sessions: [source, paymentCallback], - originPromptBySessionId: new Map(), - }); - - assert.notEqual(decision.kind, 'new_session'); - if (decision.kind === 'target') { - assert.equal(decision.target.sessionId, source.target.sessionId); - assert.notEqual(decision.evidence, 'route_correction'); - assert.equal(decision.correctedFrom, undefined); - } -}); - -test('a candidate name cannot absorb unquoted withdrawal semantics', () => { - const source = session('source', { sessionName: 'Source', updatedAt: 30 }); - for (const [text, sessionName] of [ - ["No, use Payments and don't proceed", "Payments and don't proceed"], - ['不是这个,转到支付任务然后不要继续', '支付任务然后不要继续'], - ['No, use Payments and stop.', 'Payments and stop'], - ['不是这个,转到支付任务然后停止。', '支付任务然后停止'], - ['No, use Payments and abort.', 'Payments and abort'], - ['No, use Payments and cancel.', 'Payments and cancel'], - ['No, use Payments and stop now.', 'Payments and stop now'], - ['No, use Payments and halt this.', 'Payments and halt this'], - ['No, use Payments and ABORT.', 'Payments and ABORT'], - ['No, use Payments but abort.', 'Payments but abort'], - ['No, use Payments; stop now.', 'Payments; stop now'], - ['No, use Payments. Abort.', 'Payments. Abort'], - ['No, use Payments, cancel.', 'Payments, cancel'], - ['不是这个,转到支付任务然后作罢。', '支付任务然后作罢'], - ['不是这个,转到支付任务然后停止执行。', '支付任务然后停止执行'], - ['不是这个,转到支付任务但是作罢。', '支付任务但是作罢'], - ['不是这个,转到支付任务。作罢。', '支付任务。作罢'], - ['No, use Payments. I changed my mind.', 'Payments. I changed my mind'], - [ - 'No, use Payments. On second thought, keep it here.', - 'Payments. On second thought, keep it here', - ], - ['不是这个,转到支付任务。我改主意了。', '支付任务。我改主意了'], - ] as const) { - const candidate = session('candidate', { sessionName, updatedAt: 20 }); - const policy = createWorkHubRoutePolicy(); - policy.rememberTarget(source.target); - const decision = policy.resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [source, candidate], - originPromptBySessionId: new Map(), - }); - assert.notEqual(decision.kind === 'target' ? decision.evidence : undefined, 'route_correction'); - } -}); - -test('malformed or unbound creation naming stays in WorkHub discussion', () => { - for (const text of [ - 'Create a new Session called "Payments', - '创建一个新会话叫“支付任务', - "No, don't create a new session called Login; instead create a new session for Payments", - "Create a new Session called Payments and don't proceed.", - '创建一个新会话叫支付任务然后不要继续。', - 'Create a new Session called Payments and stop.', - 'Create a new Session called Payments and abort.', - 'Create a new Session called Payments and cancel.', - 'Create a new Session called Payments and stop now.', - 'Create a new Session called Payments and halt this operation immediately.', - 'Create a new Session called Payments and ABORT.', - 'Create a new Session called Payments but abort.', - 'Create a new Session called Payments; stop now.', - 'Create a new Session called Payments. Abort.', - 'Create a new Session called Payments, cancel.', - '创建一个新会话叫支付任务然后作罢。', - '创建一个新会话叫支付任务然后停止执行。', - '创建一个新会话叫支付任务但是作罢。', - '创建一个新会话叫支付任务。作罢。', - 'No, create a new Session called Payments. Example: create a new Session called Fraud.', - 'Create a new Session called Payments.Example: create a new Session called Fraud', - 'Create a new Session called App. Example: create a new Session called Fraud', - 'No, create a new Session called Payments. Fix login, cancel this task.', - ]) { - assert.equal( - createWorkHubRoutePolicy().resolve({ - newSessionFallbackTitle: '新工作', - text, - sessions: [], - originPromptBySessionId: new Map(), - }).kind, - 'discussion', - text, - ); - } -}); - -test('subscribe exposes Session invalidations without inventing WorkHub state', () => { - let listener: (() => void) | undefined; - let unsubscribed = false; - const sessions = port([]); - sessions.subscribe = (handler) => { - listener = handler; - return () => { - unsubscribed = true; - }; - }; - const controller = createWorkHubController({ sessions }); - let invalidations = 0; - - const unsubscribe = controller.subscribe(() => { - invalidations += 1; - }); - listener?.(); - unsubscribe(); - - assert.equal(invalidations, 1); - assert.equal(unsubscribed, true); -}); - -for (const createStrategy of [createWorkHubR24RoutingStrategy, () => createWorkHubR3ARoutingStrategy({ model: { decide: async () => assert.fail('named resume must not invoke a model') } }), () => createWorkHubR3BRoutingStrategy({ model: { decide: async () => assert.fail('named resume must not invoke a model') } })]) { - const routingStrategy = createStrategy(); - test(`named resume retains ${routingStrategy.strategyId} through the shared coordination.act port`, async () => { - const controller = createGatedWorkHubController({ - sessions: port([session('payments', { sessionName: 'Payments' })]), - routingStrategy, - coordination: { - open: async () => ({ close: async () => undefined }), - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => ({ candidateSetId: `sha256:${'e'.repeat(64)}`, candidates: [{ candidateRef: 'payments-ref', sessionId: 'payments', sessionName: 'Payments', latestDelegationActionId: 'source-action', workspace: { target: { kind: 'host_path' as const, path: '/workspace/payments' }, hostCwd: '/workspace/payments' }, state: 'active' as const, updatedAt: 1 }] }), - act: async (input) => { - assert.equal(input.proposal.disposition, 'resume_work'); - assert.equal(input.proposal.resumesActionId, 'source-action'); - return { disposition: 'resume_work', outcome: 'resume_started', targetSessionId: 'payments', targetTurnId: 'resumed-turn' }; - }, - }, - }); - const result = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'resume-strategy', text: 'Resume Payments' }); - assert.equal(result.kind, 'resume'); - assert.equal(result.strategyId, routingStrategy.strategyId); - if (result.kind === 'resume') assert.equal(result.outcome, 'resume_started'); - }); -} - -for (const makeStrategy of [createWorkHubR24RoutingStrategy, () => createWorkHubR3ARoutingStrategy({ model: { decide: async (input) => input.stage === 'intent' ? { intent: 'work' } : { kind: 'none' } } }), () => createWorkHubR3BRoutingStrategy({ model: { decide: async () => ({ intent: 'work' }) } })]) { - test(`all combinations preserve Policy exact naming outside model recall budget: ${makeStrategy().strategyId}`, async () => { - const entries = Array.from({ length: 14 }, (_, i) => session(`work-${i}`, { sessionName: `任务编号${i}边界`, updatedAt: 14 - i })); - const controller = createWorkHubController({ sessions: port(entries), routingStrategy: makeStrategy() }); - const result = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'outside-recall-budget', text: '任务编号13边界:补充测试' }); - assert.equal(result.kind, 'submitted'); - if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'work-13'); - }); -} - -test('Policy freezes visit focus before awaiting replaceable Intent', async () => { - let release!: () => void; - let started!: () => void; - const entered = new Promise((resolve) => { started = resolve; }); - const pending = new Promise((resolve) => { release = resolve; }); - const strategy = createWorkHubR24RoutingStrategy(); - const controller = createWorkHubController({ - sessions: port([session('login'), session('payment')]), - routingStrategy: { ...strategy, intent: { async classify(input) { - started(); - await pending; - return strategy.intent.classify(input); - } } }, - }); - await controller.read({ focus: { sessionId: 'login' } }); - const result = controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'frozen-focus', text: '继续它' }); - await entered; - await controller.read({ focus: { sessionId: 'payment' } }); - release(); - const submitted = await result; - assert.equal(submitted.kind, 'submitted'); - if (submitted.kind === 'submitted') assert.equal(submitted.target.sessionId, 'login'); -}); - -test('deterministic routing preserves executable instructions after the model text cutoff', async () => { - const sessions = port([]); - sessions.create = async () => session('ledger'); - const controller = createWorkHubController({ sessions }); - const result = await controller.submit({ - newSessionFallbackTitle: 'New work', - requestId: 'long-executable-input', - text: '背景资料:' + '日志内容。'.repeat(450) + '\n请实现账本边界检查器', - }); - assert.equal(result.kind, 'submitted'); - if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'ledger'); -}); - - -test('composer defaults apply only to creation while attachments follow explicit and automatic routing', async () => { - const actions: WorkHubCoordinationActInput[] = []; - const newWorkDefaults = { model: { llmConnectionId: 'chosen', llmConnectionSlug: 'chosen', model: 'chosen-model' }, permissionMode: 'bypass' as const }; - const attachments: NonNullable = [{ name: 'requirements.txt', kind: 'other', mimeType: 'text/plain', bytes: 12, ref: { kind: 'session_file', sessionId: 'maka_workhub_coordination', relativePath: 'file-1' } }]; - const existing = createWorkHubController({ sessions: port([session('payments')]), onAct: (input) => actions.push(input) }); - await existing.submit({ newSessionFallbackTitle: 'New work', requestId: 'explicit-composer', text: 'Continue payments', explicitTarget: { sessionId: 'payments' }, newWorkDefaults, attachments }); - assert.equal(actions[0]?.proposal.disposition, 'delegate_existing'); - assert.equal(actions[0]?.newWorkDefaults, undefined); - assert.deepEqual(actions[0]?.attachments, attachments); - const freshPort = port([]); - freshPort.create = async () => session('created-work'); - const fresh = createWorkHubController({ sessions: freshPort, onAct: (input) => actions.push(input) }); - await fresh.submit({ newSessionFallbackTitle: 'New work', requestId: 'new-composer', text: 'Create a new Session for an accessibility audit', newWorkDefaults, attachments }); - assert.equal(actions[1]?.proposal.disposition, 'create_new'); - assert.deepEqual(actions[1]?.newWorkDefaults, newWorkDefaults); - assert.deepEqual(actions[1]?.attachments, attachments); -}); diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts deleted file mode 100644 index 5feca3844c..0000000000 --- a/apps/desktop/src/main/__tests__/workhub-coordination-host-scope.test.ts +++ /dev/null @@ -1,112 +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 assert from 'node:assert/strict'; -import test from 'node:test'; -import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; -import { scopeWorkHubSessionsToCoordinationHost } from '../../renderer/workhub-coordination-host-scope.js'; -import { - startWorkHubCoordinationLifecycle, - type WorkHubCoordinationHostChange, -} from '../../renderer/workhub-coordination-lifecycle.js'; -import type { WorkHubDesktopSessionBridge } from '../../renderer/workhub-session-port.js'; - -test('WorkHub projections follow the resolved Coordination Session Host only', async () => { - const sessionA = desktopSessionKey({ hostId: 'host-a', sessionId: 'ordinary-a' }); - const sessionB = desktopSessionKey({ hostId: 'host-b', sessionId: 'ordinary-b' }); - let coordinationSessionId: string | undefined; - let coordinationGeneration = 0; - let activeHostId = 'host-a'; - let hostChange: ((event: WorkHubCoordinationHostChange) => void) | undefined; - const baseSessions: WorkHubDesktopSessionBridge = { - list: async () => [ordinarySession(sessionA), ordinarySession(sessionB)], - listWithCoverage: async () => ({ - sessions: [ordinarySession(sessionA), ordinarySession(sessionB)], - completeHostIds: ['host-a', 'host-b'], - }), - listTurns: async () => [], - queryMessageExecutions: async () => ({ resolutions: [] }), - subscribeChanges: () => () => undefined, - }; - const scopeSessions = () => { - const generation = coordinationGeneration; - return scopeWorkHubSessionsToCoordinationHost( - baseSessions, - { - sessionId: coordinationSessionId, - isCurrent: () => generation === coordinationGeneration, - }, - ); - }; - let sessions = scopeSessions(); - const stop = startWorkHubCoordinationLifecycle({ - resolve: async () => - desktopSessionKey({ - hostId: activeHostId, - sessionId: 'maka_workhub_coordination', - }), - subscribeHostChanges(handler) { - hostChange = handler; - return () => undefined; - }, - subscribeAvailabilityChanges: () => () => undefined, - onResolving: () => { - coordinationGeneration += 1; - coordinationSessionId = undefined; - sessions = scopeSessions(); - }, - onResolved: (sessionId) => { - coordinationSessionId = sessionId; - sessions = scopeSessions(); - }, - reportFailure: (error) => assert.fail(error instanceof Error ? error.message : String(error)), - }); - - const unresolvedList = sessions.list(); - assert.deepEqual(await unresolvedList, []); - await Promise.resolve(); - assert.deepEqual((await sessions.list()).map((session) => session.id), [sessionA]); - await assert.rejects( - () => sessions.listTurns(sessionB), - /another Runtime Host/, - ); - assert.deepEqual(await sessions.listWithCoverage?.(), { - sessions: [ordinarySession(sessionA)], - completeHostIds: ['host-a'], - }); - const staleHostAScope = sessions; - - activeHostId = 'host-b'; - hostChange?.({ isDefault: true, readiness: 'ready' }); - assert.deepEqual(await sessions.list(), []); - await Promise.resolve(); - assert.deepEqual((await sessions.list()).map((session) => session.id), [sessionB]); - await assert.rejects(staleHostAScope.listTurns(sessionA), /scope is revoked/); - stop(); -}); - -function ordinarySession(id: string): Awaited>[number] { - return { - id, - name: id, - labels: [], - isArchived: false, - status: 'active', - }; -} diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts index 55ac1c1fff..13ec4ebcc0 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-lifecycle.test.ts @@ -23,7 +23,7 @@ import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; import { startWorkHubCoordinationLifecycle, type WorkHubCoordinationHostChange, -} from '../../renderer/workhub-coordination-lifecycle.js'; +} from '../../renderer/features/workhub/index.js'; const coordinationSessionId = (hostId: string) => desktopSessionKey({ hostId, diff --git a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts index 8d3220cfb8..df56374909 100644 --- a/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts +++ b/apps/desktop/src/main/__tests__/workhub-coordination-transcript-preload.test.ts @@ -27,13 +27,13 @@ import { build } from 'esbuild'; import type { StoredMessage } from '@maka/core/session'; import type { MakaBridge } from '../../preload/bridge-contract.js'; import type { DesktopTranscriptBatch, DesktopTranscriptRangeRequest } from '../../preload/transcript-contract.js'; -import { createDesktopWorkHubCoordinationPort } from '../../renderer/workhub-coordination-port.js'; +import { createDesktopWorkHubServices } from '../../renderer/platform/desktop/create-workhub-services.js'; import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; import { encodeDesktopTranscriptSnapshot } from '../desktop-transcript-ipc.js'; // Keep the real preload's navigation defaults and filtering in this consumer // regression; the IPC stub models the observer's authoritative reset reply. -test('Coordination tail recovery converges through the preload with a fragmented sparse tail', { timeout: 5_000 }, async () => { +test('WorkHub tail navigation converges through the preload with a fragmented sparse tail', { timeout: 5_000 }, async (t) => { const owner = { hostId: 'owner-host', targetEpoch: 'owner-epoch', profileId: 'local', profileName: 'Local', profileKind: 'local', profileAccess: 'owner', readiness: 'ready', @@ -50,7 +50,6 @@ test('Coordination tail recovery converges through the preload with a fragmented const requests: DesktopTranscriptRangeRequest[] = []; const projections: string[][] = []; const partialProjectionCounts: number[] = []; - const errors: unknown[] = []; let bridge: MakaBridge | undefined; let consumerId: string; let deliverySequence = 0; @@ -130,33 +129,37 @@ test('Coordination tail recovery converges through the preload with a fragmented Uint8Array, crypto: globalThis.crypto, }); assert.ok(bridge); - const port = createDesktopWorkHubCoordinationPort({ - sessionId, + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { search: '?surface=workhub' } } }); + t.after(() => { + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); + else Reflect.deleteProperty(globalThis, 'window'); + }); + const services = createDesktopWorkHubServices({ + ...bridge, transcripts: { + ...bridge.transcripts, open(requestedSessionId, handler, registerCancellation) { deliverDirect = handler; return bridge!.transcripts.open(requestedSessionId, handler, registerCancellation); }, }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => assert.fail('unused'), - act: async () => assert.fail('unused'), }); - const handle = await port.open( - (turns) => projections.push(turns.map((turn) => turn.messageId)), - (error) => errors.push(error), + const handle = await services.openTranscript( + sessionId, + (snapshot) => projections.push(snapshot.messages.map((message) => message.id)), + new AbortController().signal, ); try { + await handle.loadLatest(); await responseDelivered; await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(errors, []); assert.equal(requests.length, 1); assert.equal(requests[0]!.navigationVersion, 1); assert.equal(requests[0]!.intent, 'followTail'); assert.equal(requests[0]!.anchorSequence, null); - assert.equal(requests[0]!.maxBytes, 512 * 1024); - assert.deepEqual(partialProjectionCounts, [0, 0]); - assert.deepEqual(projections, [['latest-message']]); + assert.deepEqual(partialProjectionCounts, [1, 1]); + assert.deepEqual(projections, [[], ['latest-message']]); } finally { await handle.close(); } diff --git a/apps/desktop/src/main/__tests__/workhub-presentation.test.ts b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts new file mode 100644 index 0000000000..e5c1c29680 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-presentation.test.ts @@ -0,0 +1,422 @@ +/* + * 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 assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import test from 'node:test'; +import { build } from 'esbuild'; +import type { createMainWindowController } from '../main-window.js'; +import type { createWorkHubPresentation } from '../workhub-presentation.js'; + +const source = fileURLToPath(new URL('../../../src/main/workhub-presentation.ts', import.meta.url)); + +async function harness(animate = false) { + let now = 0; + let timerId = 0; + const timers = new Map void }>(); + const advance = (milliseconds: number) => { + const end = now + milliseconds; + for (;;) { + const next = [...timers].sort((a, b) => a[1].at - b[1].at)[0]; + if (!next || next[1].at > end) break; + now = next[1].at; + timers.delete(next[0]); + next[1].callback(); + } + now = end; + }; + const windows: FakeWindow[] = []; + const views: FakeView[] = []; + const errors: unknown[] = []; + let handler: ((event: unknown, command: string, payload?: unknown) => Promise) | undefined; + let unregistered = false; + let registeredViews = 0; + let releasedViews = 0; + let pointerDisplay = { x: 0, y: 0, width: 1200, height: 900 }; + class Contents extends EventEmitter { + mainFrame = {}; + destroyed = false; + sent: [string, ...unknown[]][] = []; + session = { setPermissionCheckHandler() {}, setPermissionRequestHandler() {} }; + isDestroyed() { return this.destroyed; } + send(channel: string, ...args: unknown[]) { this.sent.push([channel, ...args]); } + getZoomFactor() { return 1; } + captures = 0; + async capturePage() { + this.captures++; + return { toDataURL: () => 'data:image/png;base64,workhub-frame' }; + } + setWindowOpenHandler() {} + loadURL() { return Promise.resolve(); } + focus() {} + close() { this.destroyed = true; this.emit('destroyed'); } + } + class FakeWindow extends EventEmitter { + private readonly contents = new Contents(); + get webContents() { + if (this.destroyed) throw new Error('Object has been destroyed'); + return this.contents; + } + children = new Set(); + contentView = { addChildView: (v: FakeView) => this.children.add(v), removeChildView: (v: FakeView) => this.children.delete(v) }; + visible = false; + destroyed = false; + bounds = { x: 0, y: 0, width: 1000, height: 800 }; + constructor(options?: { x?: number; y?: number; width?: number; height?: number }) { + super(); + if (options) this.bounds = { x: options.x ?? 0, y: options.y ?? 0, width: options.width ?? 1000, height: options.height ?? 800 }; + windows.push(this); + } + isDestroyed() { return this.destroyed; } + isVisible() { return this.visible; } + isFocused() { return this.visible; } + isMinimized() { return false; } + getContentBounds() { return this.bounds; } + getBounds() { return this.bounds; } + setBounds(bounds: typeof this.bounds) { this.bounds = bounds; } + setVisibleOnAllWorkspaces() {} + setMaximizable() {} + show() { this.visible = true; } + hide() { this.visible = false; } + focused = 0; + focus() { this.focused++; } + restore() {} + destroy() { this.destroyed = true; this.contents.close(); this.emit('closed'); } + } + class FakeView { + webContents = new Contents(); + visible = false; + constructor() { views.push(this); } + setVisible(value: boolean) { this.visible = value; } + getVisible() { return this.visible; } + setBackgroundColor() {} + setBounds() {} + } + const output = await build({ entryPoints: [source], bundle: true, write: false, format: 'cjs', platform: 'node', external: ['electron'] }); + const module = { exports: {} as { createWorkHubPresentation: typeof createWorkHubPresentation } }; + const nodeRequire = createRequire(import.meta.url); + runInNewContext(output.outputFiles[0]!.text, { + module, exports: module.exports, console, process, URL, Error, + Date: class extends Date { static now() { return now; } }, + setTimeout: (callback: () => void, delay: number) => { timers.set(++timerId, { at: now + delay, callback }); return timerId; }, + clearTimeout: (id: number) => timers.delete(id), + require: (name: string) => name === 'electron' ? { + BrowserWindow: FakeWindow, WebContentsView: FakeView, + systemPreferences: { getAnimationSettings: () => ({ prefersReducedMotion: !animate }) }, + globalShortcut: { register: () => true, unregister: () => { unregistered = true; } }, + ipcMain: { handle: (_channel: string, callback: typeof handler) => { handler = callback; }, removeHandler: () => { handler = undefined; } }, + screen: { getCursorScreenPoint: () => ({ x: pointerDisplay.x, y: pointerDisplay.y }), getDisplayNearestPoint: () => ({ workArea: pointerDisplay }), getDisplayMatching: () => ({ workArea: { x: 0, y: 0, width: 1200, height: 900 } }) }, + } : nodeRequire(name), + }); + const main = new FakeWindow(); + const controller = module.exports.createWorkHubPresentation({ + mainWindow: () => main as unknown as Electron.BrowserWindow, + ensureMainWindow: async () => main as unknown as Electron.BrowserWindow, + mainModuleDirectory: '/app/dist/main', preloadPath: '/app/dist/preload/preload.cjs', + onError: (error) => errors.push(error), + onViewCreated: () => { registeredViews++; return () => { releasedViews++; }; }, + }); + controller.attachMainWindow(main as unknown as Electron.BrowserWindow); + controller.registerIpc(); + const command = (sender: Contents, name: string, payload?: unknown) => handler!({ sender, senderFrame: sender.mainFrame }, name, payload); + return { controller, main, windows, views, errors, command, advance, movePointer: (display: typeof pointerDisplay) => { pointerDisplay = display; }, registrations: () => [registeredViews, releasedViews], handler: () => handler, unregistered: () => unregistered }; +} + +test('yields the docked native view to main-window overlays without replacing the conversation', async () => { + const h = await harness(); + const host = { visible: true, rect: { x: 200, y: 40, width: 800, height: 760 } }; + await h.command(h.main.webContents, 'host', host); + const view = h.views[0]!; + h.main.show(); + await h.command(view.webContents, 'ready'); + assert.equal(view.visible, true); + const backdrop = await h.command(h.main.webContents, 'host', { ...host, occluded: true }); + assert.equal(backdrop, 'data:image/png;base64,workhub-frame'); + assert.equal(view.visible, false); + await h.command(h.main.webContents, 'host', { ...host, occluded: true }); + assert.equal(view.webContents.captures, 1); + await h.command(h.main.webContents, 'host', host); + assert.equal(view.visible, true); + assert.equal(h.views.length, 1); + await h.command(view.webContents, 'detach'); + await h.command(h.main.webContents, 'host', { ...host, occluded: true }); + assert.equal(view.visible, true); + assert.equal(view.webContents.captures, 1); + await assert.rejects(h.command(h.main.webContents, 'host', { ...host, occluded: 'yes' }), /Invalid WorkHub host/); + h.controller.dispose(); +}); + +test('yields and restores the conversation when its compositor frame is unavailable', async () => { + const h = await harness(); + const host = { visible: true, rect: { x: 200, y: 40, width: 800, height: 760 } }; + await h.command(h.main.webContents, 'host', host); + const view = h.views[0]!; + const occlude = () => h.command(h.main.webContents, 'host', { ...host, occluded: true }); + const restore = () => h.command(h.main.webContents, 'host', host); + h.main.show(); + assert.equal(await occlude(), undefined); + assert.equal(view.webContents.captures, 0); + await restore(); + await h.command(view.webContents, 'ready'); + h.main.hide(); + assert.equal(await occlude(), undefined); + assert.equal(view.webContents.captures, 0); + await restore(); + h.main.show(); + view.webContents.capturePage = async () => { throw new Error('UnknownVizError'); }; + assert.equal(await occlude(), undefined); + assert.equal(view.visible, false); + assert.deepEqual(h.errors, []); + await restore(); + assert.equal(view.visible, true); + assert.equal(h.views.length, 1); + const unexpected = new Error('Unexpected capture failure'); + view.webContents.capturePage = async () => { throw unexpected; }; + await occlude(); + assert.deepEqual(h.errors, [unexpected]); + await restore(); + assert.equal(view.visible, true); + h.controller.dispose(); +}); + +test('opens an empty floating conversation at its composer height', async () => { + const h = await harness(); + await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 0, y: 40, width: 1000, height: 760 } }); + const view = h.views[0]!; + await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 110 }); + await h.command(view.webContents, 'detach'); + assert.equal(h.windows[1]!.bounds.height, 110); + await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 110 }); + assert.equal(h.windows[1]!.bounds.height, 720); + await h.command(view.webContents, 'dock'); + await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 110 }); + h.movePointer({ x: 1600, y: -900, width: 1000, height: 800 }); + await h.controller.toggle(true); + const bounds = h.windows[1]!.bounds; + assert.equal(bounds.x + bounds.width / 2, 2100); + assert.equal(bounds.y + bounds.height, -100 - 96); + h.controller.dispose(); +}); + +test('reopening or docking a crashed conversation creates a ready-gated renderer', async () => { + const h = await harness(); + const host = { visible: true, rect: { x: 0, y: 40, width: 1000, height: 760 } }; + await h.command(h.main.webContents, 'host', host); + for (const recover of [ + () => h.controller.show(), + () => h.command(h.main.webContents, 'dock'), + ]) { + const previous = h.views.at(-1)!; + await h.command(previous.webContents, 'ready'); + const staleReady = h.command(previous.webContents, 'ready'); + previous.webContents.emit('render-process-gone', {}, { reason: 'crashed' }); + await assert.rejects(staleReady, /owned main frame/); + await h.command(h.main.webContents, 'host', host); + assert.equal(h.controller.getSnapshot().rendererCrashed, true); + assert.equal(h.views.at(-1), previous, 'recovery waits for an explicit user action'); + await recover(); + assert.equal(h.controller.getSnapshot().rendererCrashed, false); + const recovered = h.views.at(-1)!; + assert.notEqual(recovered, previous); + assert.equal(previous.webContents.isDestroyed(), true); + assert.equal(h.windows.some((window) => window.children.has(previous)), false); + assert.equal(h.controller.ownsWebContents(previous.webContents as unknown as Electron.WebContents), false); + assert.equal(recovered.webContents.sent.some(([channel]) => channel === 'workhub-presentation:focus-composer'), false); + await h.command(recovered.webContents, 'ready'); + assert.equal(recovered.webContents.sent.some(([channel]) => channel === 'workhub-presentation:focus-composer'), true); + } + assert.deepEqual(h.registrations(), [3, 2]); + h.controller.dispose(); + assert.deepEqual(h.registrations(), [3, 3]); +}); + +test('animates from the current height, keeps the bottom anchored and survives reversal', async () => { + const h = await harness(true); + await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 0, y: 40, width: 1000, height: 760 } }); + const view = h.views[0]!; + await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 110 }); + await h.command(view.webContents, 'detach'); + const floating = h.windows[1]!; + const bottom = floating.bounds.y + floating.bounds.height; + await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 110 }); + h.advance(80); + assert.ok(floating.bounds.height > 110 && floating.bounds.height < 720); + assert.equal(floating.bounds.y + floating.bounds.height, bottom); + // A composer measurement during expansion must not restart or shrink it. + await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 114 }); + h.advance(160); + assert.equal(floating.bounds.height, 720); + await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 110 }); + h.advance(80); + const intermediate = floating.bounds.height; + assert.ok(intermediate > 110 && intermediate < 720); + await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 110 }); + assert.equal(floating.bounds.height, intermediate); + h.advance(240); + assert.equal(floating.bounds.height, 720); + assert.equal(floating.bounds.y + floating.bounds.height, bottom); + await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 110 }); + h.advance(80); + await h.command(view.webContents, 'hide'); + const hiddenBounds = floating.bounds; + h.advance(500); + assert.equal(floating.bounds, hiddenBounds); + h.controller.dispose(); +}); + +test('reparents one live conversation across docking, floating, hide and main-window close', async () => { + const h = await harness(); + await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 100, y: 40, width: 900, height: 760 } }); + const view = h.views[0]!; + await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 96 }); + assert.ok(h.main.children.has(view)); + await h.command(view.webContents, 'detach'); + const floating = h.windows[1]!; + assert.ok(!h.main.children.has(view) && floating.children.has(view)); + const expandedHeight = floating.bounds.height; + const anchoredBottom = floating.bounds.y + floating.bounds.height; + await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 160 }); + assert.equal(floating.bounds.height, 160); + assert.equal(floating.bounds.y + floating.bounds.height, anchoredBottom); + await h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: 200 }); + assert.equal(floating.bounds.height, 200); + await h.command(view.webContents, 'conversation-layout', { expanded: true, compactHeight: 200 }); + assert.equal(floating.bounds.height, expandedHeight); + assert.equal(floating.bounds.y + floating.bounds.height, anchoredBottom); + await assert.rejects(h.command(h.main.webContents, 'conversation-layout', { expanded: false, compactHeight: 160 }), /Only the WorkHub view/); + await assert.rejects(h.command(view.webContents, 'conversation-layout', { expanded: false, compactHeight: Number.NaN }), /Invalid WorkHub conversation layout/); + await h.command(view.webContents, 'hide'); + assert.equal(floating.visible, false); + assert.equal(view.webContents.destroyed, false); + await h.command(view.webContents, 'dock'); + assert.ok(h.main.children.has(view) && !floating.children.has(view)); + await h.command(h.main.webContents, 'host', { visible: false, rect: { x: 0, y: 0, width: 0, height: 0 } }); + assert.equal(view.visible, false); + h.main.emit('close'); + h.main.hide(); + const mainFocusCount = h.main.focused; + assert.ok(floating.children.has(view)); + assert.equal(view.webContents.destroyed, false); + await h.controller.toggle(); + assert.equal(h.controller.getSnapshot().placement, 'floating'); + assert.ok(floating.children.has(view)); + assert.equal(floating.visible, true); + await h.controller.toggle(); + assert.equal(floating.visible, false); + assert.equal(h.main.visible, false, 'hiding the floating window must not show Desktop'); + assert.equal(h.main.focused, mainFocusCount, 'the shortcut never focuses Desktop'); + assert.ok(floating.children.has(view)); + await h.controller.toggle(); + assert.equal(floating.visible, true); + h.movePointer({ x: 1600, y: -900, width: 1000, height: 800 }); + await Promise.all([h.controller.toggle(), h.controller.toggle()]); + assert.equal(h.controller.getSnapshot().placement, 'floating'); + assert.equal(floating.visible, true); + assert.ok(floating.bounds.x >= 1600 && floating.bounds.x + floating.bounds.width <= 2600); + assert.ok(floating.bounds.y >= -900 && floating.bounds.y + floating.bounds.height <= -100); + assert.equal(h.main.visible, false); + assert.equal(h.main.focused, mainFocusCount); + await h.command(view.webContents, 'dock'); + assert.equal(h.controller.getSnapshot().placement, 'docked'); + assert.equal(h.main.visible, true, 'only the explicit dock action returns to Desktop'); + assert.ok(h.main.children.has(view)); + assert.equal(h.views.length, 1); + assert.doesNotThrow(() => h.main.destroy()); + assert.doesNotThrow(() => h.controller.send('settings:changed')); + h.controller.registerShortcut(); + h.controller.dispose(); + assert.equal(view.webContents.destroyed, true); + assert.equal(floating.destroyed, true); + assert.equal(h.unregistered(), true); + assert.equal(h.handler(), undefined); + assert.deepEqual(h.registrations(), [1, 1]); +}); + +test('rejects unowned/subframe IPC and buffers navigation until main subscribes', async () => { + const h = await harness(); + await assert.rejects(h.handler()!({ sender: h.main.webContents, senderFrame: {} }, 'snapshot'), /owned main frame/); + await h.controller.toggle(); + const view = h.views[0]!; + await assert.rejects(h.command(view.webContents, 'host', {}), /Only the main window/); + await h.command(view.webContents, 'session', JSON.stringify(['host-a', 'session-a'])); + assert.equal(h.main.webContents.sent.some(([channel]) => channel.endsWith('open-main')), false); + await h.command(h.main.webContents, 'ready'); + const navigation = h.main.webContents.sent.find(([channel]) => channel.endsWith('open-main')); + assert.equal(JSON.stringify(navigation?.[1]), JSON.stringify({ kind: 'session', sessionKey: '["host-a","session-a"]' })); + h.controller.dispose(); +}); + +test('application broadcasts reach registered auxiliaries once and stop after release or destruction', async () => { + const entry = fileURLToPath(new URL('../../../src/main/main-window.ts', import.meta.url)); + const output = await build({ entryPoints: [entry], bundle: false, write: false, format: 'cjs', platform: 'node', define: { 'import.meta.dirname': JSON.stringify('/app/dist/main') } }); + const module = { exports: {} as { createMainWindowController: typeof createMainWindowController } }; + runInNewContext(output.outputFiles[0]!.text, { + module, exports: module.exports, process, + require: () => ({ createWindowRevealGate: () => ({}) }), + }); + const controller = module.exports.createMainWindowController({ + workspaceRoot: '/workspace', e2eFixture: null, revealMode: 'hidden', + settingsStore: { get: async () => { throw new Error('Unused'); } }, + onRendererProcessGone: () => undefined, + }); + const messages: string[] = []; + const renderer = Object.assign(new EventEmitter(), { + isDestroyed: () => false, + send: (channel: string) => { messages.push(channel); }, + }) as unknown as Electron.WebContents; + const release = controller.registerAuxiliaryRenderer(renderer); + assert.equal(controller.ownsRenderer(renderer), true); + controller.send('settings:changed'); + assert.deepEqual(messages, ['settings:changed']); + release(); + controller.send('settings:changed'); + assert.deepEqual(messages, ['settings:changed']); + assert.equal(controller.ownsRenderer(renderer), false); + controller.registerAuxiliaryRenderer(renderer); + renderer.emit('destroyed'); + assert.equal(controller.ownsRenderer(renderer), false); + controller.send('settings:changed'); + assert.deepEqual(messages, ['settings:changed']); +}); + +test('control preparation floats the live conversation and focuses the main window without resetting an existing float', async () => { + const h = await harness(); + await h.command(h.main.webContents, 'host', { visible: true, rect: { x: 0, y: 0, width: 1000, height: 800 } }); + const view = h.views[0]!; + await h.controller.prepareControl(); + const floating = h.windows[1]!; + assert.equal(h.controller.getSnapshot().placement, 'floating'); + assert.ok(floating.visible && floating.children.has(view)); + assert.equal(h.main.children.has(view), false); + assert.equal(h.main.focused, 1); + floating.setBounds({ x: 120, y: 130, width: 520, height: 650 }); + const floatingFocus = floating.focused; + await h.controller.prepareControl(); + assert.deepEqual(floating.bounds, { x: 120, y: 130, width: 520, height: 650 }); + assert.equal(floating.focused, floatingFocus, 'a later control call must not refocus the composer'); + assert.equal(h.main.focused, 2); + assert.equal(h.views.length, 1); + await h.command(view.webContents, 'hide'); + await h.controller.prepareControl(); + assert.equal(floating.visible, true); + h.controller.dispose(); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-routing-experiment.test.ts b/apps/desktop/src/main/__tests__/workhub-routing-experiment.test.ts deleted file mode 100644 index 03c1d45d56..0000000000 --- a/apps/desktop/src/main/__tests__/workhub-routing-experiment.test.ts +++ /dev/null @@ -1,51 +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 assert from 'node:assert/strict'; -import test from 'node:test'; -import { runRoutingComparison, session } from './workhub-controller-fixture.js'; - -test('comparison hydrates the same transcript and candidate set into every fresh controller', async () => { - const sessions = [session('payment', { sessionName: '支付回调幂等性' })]; - const transcript = [{ messageId: 'history', turnId: 'history', text: '此前讨论了支付重试', result: '保留幂等性约束', state: 'completed' as const, updatedAt: 1 }]; - const before = structuredClone({ sessions, transcript }); - let intentCalls = 0; - const candidateSetId = `sha256:${'c'.repeat(64)}`; - const observations = await runRoutingComparison({ repetitions: 2, sessions, transcript, candidateSetId, - cases: [{ caseId: 'payment', text: '支付回调幂等性:补充重复投递测试' }], - model: { async decide(input) { - if (input.stage === 'resolver') return { kind: 'ranked', candidateRefs: ['candidate-payment'] }; - intentCalls += 1; - assert.deepEqual(input.coordinationTranscript, [{ userText: transcript[0]!.text, assistantText: transcript[0]!.result }]); - return { intent: 'work' }; - } }, - }); - assert.equal(intentCalls, 4); - assert.equal(observations.length, 6); - assert.equal(new Set(observations.map(({ result }) => result.strategyId)).size, 3); - for (const { result, proposals } of observations) { - assert.equal(result.kind, 'submitted'); - if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'payment'); - assert.equal(proposals.length, 1); - const proposal = proposals[0]!.proposal; - assert.equal(proposal.disposition, "delegate_existing"); - if (proposal.disposition === "delegate_existing") assert.equal(proposals[0]!.candidateSetId, candidateSetId); - } - assert.deepEqual({ sessions, transcript }, before); -}); diff --git a/apps/desktop/src/main/__tests__/workhub-routing-strategy.test.ts b/apps/desktop/src/main/__tests__/workhub-routing-strategy.test.ts deleted file mode 100644 index 3f1f5cd54d..0000000000 --- a/apps/desktop/src/main/__tests__/workhub-routing-strategy.test.ts +++ /dev/null @@ -1,264 +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 assert from 'node:assert/strict'; -import test from 'node:test'; -import { createWorkHubController, port, session } from './workhub-controller-fixture.js'; -import { - boundedRoutingInput, - createWorkHubR24RoutingStrategy, - createWorkHubR3ARoutingStrategy, - createWorkHubR3BRoutingStrategy, - readWorkHubRoutingEvidence, - type WorkHubRoutingInput, - type WorkHubRoutingStrategy, - type WorkHubModelRoutingRequest, -} from '../../renderer/features/workhub/index.js'; - -const sessions = [ - { - target: { sessionId: 'login' }, - projectName: 'maka', - sessionName: '登录刷新令牌', - state: 'active' as const, - updatedAt: 2, - }, - { - target: { sessionId: 'payment' }, - projectName: 'maka', - sessionName: '支付回调幂等性', - state: 'active' as const, - updatedAt: 1, - }, -]; -function fixture(text = '请实现账本边界检查器'): WorkHubRoutingInput { - return { - text, - sessions, - originPromptBySessionId: new Map(), - candidateRefBySessionId: new Map([ - ['login', 'candidate-login'], - ['payment', 'candidate-payment'], - ]), - coordinationTranscript: [], - }; -} -async function run(strategy: WorkHubRoutingStrategy, raw = fixture()) { - const sessions = port(raw.sessions.map((value) => session(value.target.sessionId, value))); - sessions.routingEvidence = async () => [...raw.originPromptBySessionId].map(([sessionId, originPrompt]) => ({ target: { sessionId }, originPrompt })); - sessions.create = async ({ name }) => session('created', { sessionName: name }); - const controller = createWorkHubController({ sessions, routingStrategy: strategy }); - return controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'combination', text: raw.text }); -} -const model = { - async decide(input: WorkHubModelRoutingRequest) { - return input.stage === 'intent' - ? { intent: 'work' } - : { kind: 'ranked', candidateRefs: ['candidate-payment'] }; - }, -}; - -test('a strategy combines two independent ports and has no decision or focus owner', async () => { - const baseline = createWorkHubR24RoutingStrategy(); - const r3 = createWorkHubR3ARoutingStrategy({ model }); - assert.deepEqual(Object.keys(r3).sort(), ['intent', 'resolver', 'strategyId']); - const intentOnly = { ...baseline, intent: r3.intent }; - const resolverOnly = { ...baseline, resolver: r3.resolver }; - assert.equal((await run(intentOnly, fixture('支付回调幂等性:补充测试'))).kind, 'submitted'); - const result = await run(resolverOnly); - assert.equal(result.kind, 'submitted'); - if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'payment'); -}); - -test('R3-A calls separate intent and recall components; neither sees Session IDs', async () => { - const requests: WorkHubModelRoutingRequest[] = []; - const strategy = createWorkHubR3ARoutingStrategy({ - model: { - async decide(input) { - requests.push(input); - return model.decide(input); - }, - }, - }); - await run(strategy); - assert.deepEqual( - requests.map(({ stage }) => stage), - ['intent', 'resolver'], - ); - assert.equal('candidates' in requests[0]!, false); - assert.equal('disposition' in requests[1]!, false); - assert.equal(JSON.stringify(requests).includes('sessionId'), false); -}); - -test('R3-B replaces only Intent and reuses the deterministic Resolver', async () => { - const stages: string[] = []; - const strategy = createWorkHubR3BRoutingStrategy({ - model: { - async decide(input) { - stages.push(input.stage); - return { intent: 'work' }; - }, - }, - }); - assert.equal((await run(strategy, fixture('支付回调幂等性:补充测试'))).kind, 'submitted'); - assert.deepEqual(stages, ['intent']); -}); - -test('all combinations see the same bounded candidate snapshot, including deterministic recall', async () => { - const raw = fixture(); - const large = Array.from({ length: 14 }, (_, i) => ({ - ...sessions[0]!, - target: { sessionId: `session-${i}` }, - sessionName: `工作 ${i}`, - updatedAt: 14 - i, - })); - const input = boundedRoutingInput({ - ...raw, - text: '工作 13', - sessions: large, - candidateRefBySessionId: new Map( - large.map((session, i) => [session.target.sessionId, `ref-${i}`]), - ), - }); - assert.equal(input.sessions.length, 12); - for (const strategy of [ - createWorkHubR24RoutingStrategy(), - createWorkHubR3ARoutingStrategy({ model }), - createWorkHubR3BRoutingStrategy({ model }), - ]) { - const seen: string[][] = []; - const wrapped = { - ...strategy, - resolver: { - async resolve(value: Parameters[0]) { - seen.push(value.candidates.map(({ candidateRef }) => candidateRef)); - return strategy.resolver.resolve(value); - }, - }, - }; - await readWorkHubRoutingEvidence(wrapped, input); - assert.deepEqual(seen, [Array.from({ length: 12 }, (_, i) => `ref-${i}`)]); - } -}); - -test('model text is bounded at the adapter while deterministic components keep full text', async () => { - const input = boundedRoutingInput({ - ...fixture('😀'.repeat(3000)), - sessions: sessions.map((session) => ({ - ...session, - sessionName: '名'.repeat(1000), - latestResult: '结'.repeat(1000), - })), - originPromptBySessionId: new Map([['login', '源'.repeat(1000)]]), - coordinationTranscript: Array.from({ length: 20 }, () => ({ userText: '文'.repeat(1000) })), - }); - assert.equal(Array.from(input.text).length, 3000); - const requests: WorkHubModelRoutingRequest[] = []; - await readWorkHubRoutingEvidence(createWorkHubR3ARoutingStrategy({ model: { async decide(value) { requests.push(value); return value.stage === "intent" ? { intent: "work" } : { kind: "none" }; } } }), input); - assert.equal(requests.length, 2); - assert.ok(requests.every((value) => Array.from(value.text).length === 2000)); - assert.equal(input.coordinationTranscript.length, 12); - assert.ok(input.sessions.every((session) => session.sessionName.length <= 600)); - assert.equal(input.originPromptBySessionId.get('login')?.length, 600); -}); - -for (const response of [ - null, - [], - { disposition: 'create_new' }, - { intent: 'work', target: 'payment' }, -]) { - test(`malformed intent cannot issue a proposal: ${JSON.stringify(response)}`, async () => { - const strategy = createWorkHubR3ARoutingStrategy({ model: { decide: async () => response } }); - const evidence = await readWorkHubRoutingEvidence(strategy, boundedRoutingInput(fixture())); - assert.equal(evidence.classification, 'uncertain'); - assert.equal(evidence.resolution.kind, 'ambiguous'); - assert.equal((await run(strategy)).kind, 'clarification'); - }); -} -for (const response of [ - null, - { kind: 'ranked', candidateRefs: ['invented'] }, - { kind: 'ranked', candidateRefs: ['candidate-payment', 'candidate-payment'] }, - { kind: 'ranked', candidateRefs: [] }, - { kind: 'none', disposition: 'create_new' }, - { kind: 'ranked', candidateRefs: ['candidate-payment'], target: 'payment' }, -]) { - test(`malformed recall fails closed: ${JSON.stringify(response)}`, async () => { - const strategy = createWorkHubR3ARoutingStrategy({ - model: { - decide: async (input) => (input.stage === 'intent' ? { intent: 'work' } : response), - }, - }); - const evidence = await readWorkHubRoutingEvidence(strategy, boundedRoutingInput(fixture())); - assert.equal(evidence.classification, 'uncertain'); - assert.equal(evidence.resolution.kind, 'ambiguous'); - assert.equal((await run(strategy)).kind, 'clarification'); - }); -} - -test('Policy retains ambiguity, exact naming and focus with model recall in the real controller', async () => { - const strategy = createWorkHubR3ARoutingStrategy({ model }); - assert.equal((await run(strategy, fixture('创建一个新任务,不过我还不确定是否要做'))).kind, 'clarification'); - const controller = createWorkHubController({ sessions: port(sessions.map((value) => session(value.target.sessionId, value))), routingStrategy: strategy }); - const exact = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'exact', text: '登录刷新令牌:补充测试' }); - assert.equal(exact.kind, 'submitted'); - if (exact.kind === 'submitted') assert.equal(exact.target.sessionId, 'login'); - const focused = await controller.submit({ newSessionFallbackTitle: 'New work', requestId: 'focused', text: '继续它' }); - assert.equal(focused.kind, 'submitted'); - if (focused.kind === 'submitted') assert.equal(focused.target.sessionId, 'login'); -}); - -test('a ranked list is not a selected target: Policy clarifies multiple recalled candidates', async () => { - const strategy = createWorkHubR3ARoutingStrategy({ - model: { - decide: async (input) => - input.stage === 'intent' - ? { intent: 'work' } - : { kind: 'ranked', candidateRefs: ['candidate-payment', 'candidate-login'] }, - }, - }); - assert.equal((await run(strategy)).kind, 'clarification'); -}); - -test('model work intent cannot turn trusted discussion into creation or delegation', async () => { - const strategy = createWorkHubR3ARoutingStrategy({ model }); - const result = await run(strategy, fixture('讨论一下量子纠缠的概念')); - assert.notEqual(result.kind, 'submitted'); -}); - -test('trusted explicit creation is decided by Policy, never returned by a model', async () => { - const result = await run( - createWorkHubR3ARoutingStrategy({ model }), - fixture('创建一个新工作,检查账本边界'), - ); - assert.equal(result.kind, 'submitted'); - if (result.kind === 'submitted') assert.equal(result.target.sessionId, 'created'); -}); - -test('model exceptions become uncertain evidence rather than creating work', async () => { - const strategy = createWorkHubR3ARoutingStrategy({ - model: { - decide: async () => { - throw new Error('offline'); - }, - }, - }); - assert.equal((await run(strategy)).kind, 'clarification'); -}); diff --git a/apps/desktop/src/main/__tests__/workhub-runtime.test.ts b/apps/desktop/src/main/__tests__/workhub-runtime.test.ts new file mode 100644 index 0000000000..32e10d2ba2 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-runtime.test.ts @@ -0,0 +1,83 @@ +/* + * 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 assert from 'node:assert/strict'; +import test from 'node:test'; +import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session'; +import { createWorkHubRuntime } from '../workhub-runtime.js'; + +const scope = { hostId: 'host', targetEpoch: 'epoch' }; +function fixture() { + let current = true; + const requests: unknown[] = []; + const stops: unknown[] = []; + const changes: unknown[] = []; + const deps: Parameters[0] = { + isCurrent: () => current, + client: () => client, + createContext: async () => ({ workspace: { kind: 'project', projectId: 'project' }, defaults: { permissionMode: 'ask' } }), + changed: (...args) => { changes.push(args); }, + }; + const client = { + queryTurn: async () => ({ sessionId: WORKHUB_COORDINATION_SESSION_ID, turnId: 'turn', runId: 'run', status: 'running' as const }), + stopTurn: async (input: unknown) => { stops.push(input); }, + listWorkHubCoordinationCandidates: async () => ({ candidateSetId: 'set', candidates: [] }), + actWorkHubCoordinationFromTurn: async (input: unknown) => { + requests.push(input); + return { disposition: 'create_new' as const, targetSessionId: 'target', targetTurnId: 'target-turn' }; + }, + } as unknown as ReturnType; + return { deps, client, requests, stops, changes, retire: () => { current = false; }, runtime: createWorkHubRuntime(deps) }; +} + +test('task delegation binds the tool action to the Host turn and trusted creation context', async () => { + const f = fixture(); + const result = await f.runtime.actTasks(scope, 'turn', 'tool-call', { operation: 'create_new', title: 'Fix login', text: 'Implement and test the login fix' }); + assert.deepEqual(f.requests, [{ + turnId: 'turn', actionId: 'tool-call', proposal: { disposition: 'create_new', title: 'Fix login' }, + delegationText: 'Implement and test the login fix', create: { workspace: { kind: 'project', projectId: 'project' } }, + newWorkDefaults: { permissionMode: 'ask' }, + }]); + assert.ok('actionId' in result); + assert.equal(result.actionId, 'tool-call'); + assert.deepEqual(f.changes, [[scope, 'created', 'target']]); +}); + +test('a Host switch while resolving the workspace prevents delegation', async () => { + const f = fixture(); + const original = f.deps.createContext; + f.deps.createContext = async (target) => { const context = await original(target); f.retire(); return context; }; + await assert.rejects(f.runtime.actTasks(scope, 'turn', 'tool-call', { operation: 'create_new', title: 'Work', text: 'Do work' }), /Runtime Host changed/); + assert.deepEqual(f.requests, []); +}); + +test('takeover stops the exact old turn and run even after selecting another Host', async () => { + const f = fixture(); + f.retire(); + await f.runtime.interrupt(scope, 'turn'); + assert.deepEqual(f.stops, [{ sessionId: WORKHUB_COORDINATION_SESSION_ID, turnId: 'turn', runId: 'run' }]); + await assert.rejects(f.runtime.assertTurn(scope, 'turn'), /Runtime Host changed/); +}); + +test('a changed turn identity cannot become the takeover target', async () => { + const f = fixture(); + f.client.queryTurn = async () => ({ sessionId: WORKHUB_COORDINATION_SESSION_ID, turnId: 'new-turn', runId: 'new-run', status: 'running' }); + await assert.rejects(f.runtime.interrupt(scope, 'turn'), /turn identity changed/); + assert.deepEqual(f.stops, []); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts new file mode 100644 index 0000000000..1bd4e0b7bb --- /dev/null +++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts @@ -0,0 +1,141 @@ +/* + * 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 assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { LocaleProvider } from '@maka/ui'; +import { deferred } from '@maka/core/test-only/async-primitives'; +import type { AttachmentRef } from '@maka/core/events'; +import type { StoredMessage } from '@maka/core/session'; +import { WorkHubServicesProvider, type WorkHubServices, type WorkHubTranscriptSnapshot } from '../../renderer/features/workhub/index.js'; +import { useWorkHubController } from '../../renderer/features/workhub/testing.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(cleanupFakeDom); + +async function mountController() { + const { root } = installReactRenderer(); + let controller!: ReturnType; + let publish!: (snapshot: WorkHubTranscriptSnapshot) => void; + let observe!: Parameters[1]; + let loadLatestCount = 0; + const admission = deferred<{ turnId: string }>(); + const latestRead = deferred(); + const requests: Array[1]> = []; + const sessionId = JSON.stringify(['host-1', 'workhub-coordination']); + const services = { + resolve: async () => sessionId, + getSession: async () => ({ id: sessionId, runningTurnIds: [] }), + listSessions: async () => [], + modelChoices: async () => [], + subscribeHosts: () => () => {}, + subscribeAvailability: () => () => {}, + subscribeSessions: () => () => {}, + observe: (_id: string, handler: typeof observe) => { observe = handler; return () => {}; }, + openTranscript: async (_id: string, handler: typeof publish) => { + publish = handler; + handler({ messages: [], ready: true, hasOlder: false, hasNewer: false }); + return { loadOlder: async () => {}, loadLatest: () => { loadLatestCount += 1; return latestRead.promise; }, close: async () => {} }; + }, + answer: async (_id: string, input: Parameters[1]) => { + requests.push(input); + return admission.promise; + }, + } as unknown as WorkHubServices; + function Probe() { controller = useWorkHubController(); return null; } + await act(async () => { + root.render(createElement(LocaleProvider, { locale: 'en', children: + createElement(WorkHubServicesProvider, { services }, createElement(Probe)), + })); + }); + assert.equal(controller.sessionId, sessionId); + return { + get controller() { return controller; }, sessionId, requests, admission, latestRead, + get loadLatestCount() { return loadLatestCount; }, + emit(event: Parameters[0]) { observe(event); }, + publish(messages: StoredMessage[]) { publish({ messages, ready: true, hasOlder: false, hasNewer: false }); }, + }; +} + +test('WorkHub shows the submitted prompt before admission and keeps it until its durable user record arrives', async () => { + const h = await mountController(); + await act(() => { + h.emit({ type: 'text_delta', id: 'previous-output', turnId: 'previous-turn', messageId: 'previous-answer', ts: 1, text: 'Earlier answer' }); + h.emit({ type: 'abort', id: 'previous-abort', turnId: 'previous-turn', ts: 2, reason: 'user_stop' }); + }); + assert.ok(h.controller.liveTurn?.terminal); + const text = '给我改成浅色主题'; + const attachments: AttachmentRef[] = [{ kind: 'doc', name: 'brief.txt', mimeType: 'text/plain', bytes: 4, ref: { kind: 'workspace_file', relativePath: 'brief.txt' } }]; + const followed: string[] = []; + const unsubscribe = h.controller.viewportNavigation.subscribe((id) => followed.push(id)); + let sent!: Promise; + await act(async () => { sent = h.controller.send(text, attachments); }); + assert.deepEqual(h.controller.transientMessages.map((message) => message.text), [text]); + assert.deepEqual(h.controller.transientMessages[0]!.attachments, attachments); + assert.equal(h.requests.length, 1, 'the pending history read must not delay admission'); + assert.equal(h.loadLatestCount, 1); + assert.deepEqual(followed, [h.sessionId]); + const turnId = h.requests[0]!.turnId; + assert.equal(h.controller.liveTurn?.turnId, turnId, 'waiting feedback starts before admission'); + assert.equal(h.controller.liveTurn?.phase, 'waiting'); + assert.equal(h.controller.busy, true); + await act(async () => { h.admission.resolve({ turnId }); assert.equal(await sent, true); }); + assert.equal(h.controller.transientMessages.length, 1, 'an acknowledgement is not a durable message'); + await act(async () => { + h.publish([{ type: 'assistant', id: 'reply', turnId, text: 'Working on it', ts: 2, modelId: 'fixture' }]); + }); + assert.equal(h.controller.transientMessages.length, 1, 'assistant delivery cannot erase the user prompt'); + await act(async () => { + h.publish([{ type: 'user', id: 'canonical-user-id', turnId, text, attachments, ts: 1 }]); + }); + assert.equal(h.controller.transientMessages.length, 0); + assert.deepEqual(h.controller.transcript.messages.map((message) => message.id), ['canonical-user-id']); + unsubscribe(); + h.latestRead.resolve(); +}); + +test('WorkHub removes a failed submission from the conversation and preserves its retry identity', async () => { + const h = await mountController(); + let sent!: Promise; + await act(async () => { sent = h.controller.send('retry this prompt', []); }); + const turnId = h.requests[0]!.turnId; + await act(async () => { h.admission.reject(new Error('admission rejected')); assert.equal(await sent, false); }); + assert.equal(h.controller.transientMessages.length, 0); + assert.equal(h.controller.error, 'admission rejected'); + assert.equal(h.controller.liveTurn, undefined, 'rejected admission retires the waiting feedback'); + assert.equal(h.controller.busy, false); + await act(async () => { assert.equal(await h.controller.send('retry this prompt', []), false); }); + assert.equal(h.requests[1]!.turnId, turnId); + assert.equal(h.controller.transientMessages.length, 0); + h.latestRead.resolve(); +}); + +test('a lost admission response cannot erase confirmed WorkHub activity', async () => { + const h = await mountController(); + let sent!: Promise; + await act(async () => { sent = h.controller.send('keep the real activity', []); }); + const turnId = h.requests[0]!.turnId; + await act(() => h.emit({ type: 'text_delta', id: 'first-output', turnId, messageId: 'answer', ts: 1, text: 'Working' })); + await act(async () => { h.admission.reject(new Error('response lost')); assert.equal(await sent, false); }); + assert.equal(h.controller.liveTurn?.turnId, turnId); + assert.equal(h.controller.liveTurn?.unconfirmed, undefined); + assert.equal(h.controller.busy, true); + h.latestRead.resolve(); +}); diff --git a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-port.test.ts deleted file mode 100644 index e5b668ca78..0000000000 --- a/apps/desktop/src/main/__tests__/workhub-session-port.test.ts +++ /dev/null @@ -1,1160 +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 assert from 'node:assert/strict'; -import test from 'node:test'; -import type { StoredMessage } from '@maka/core/session'; -import type { DesktopTranscriptBatch } from '../../preload/transcript-contract.js'; -import { desktopSessionKey } from '../../shared/runtime-host-identity.js'; -import { - createDesktopWorkHubSessionPort, - projectWorkHubSessionTurns, - type WorkHubDesktopSession, -} from '../../renderer/workhub-session-port.js'; -import { - createDesktopWorkHubCoordinationPort, - projectWorkHubCoordinationTurns, -} from '../../renderer/workhub-coordination-port.js'; - -function desktopSession( - id: string, - overrides: Partial = {}, -): WorkHubDesktopSession { - return { - id, - name: id, - labels: [], - isArchived: false, - status: 'active', - runningTurnIds: [], - projectId: 'project-maka', - lastMessageAt: 1, - ...overrides, - }; -} - -const unusedTranscripts = { - open: async () => { - throw new Error('transcript is not used by this test'); - }, -}; - -const noMessageExecutions = async () => ({ - resolutions: [] as Array< - | { messageId: string; state: 'pending' } - | { messageId: string; state: 'cancelled' } - | { messageId: string; state: 'owned'; turnId: string; runId: string } - >, -}); - -function transcriptsWith(messages: readonly StoredMessage[]) { - return { - open: async (sessionId: string, handler: (batch: DesktopTranscriptBatch) => void) => { - const parsed = JSON.parse(sessionId) as [string, string]; - const fragments = messages.map((message, identity) => { - const data = new TextEncoder().encode(JSON.stringify(message)); - return { - source: 'durable' as const, - identity, - order: null, - byteOffset: 0, - totalBytes: data.byteLength, - data, - }; - }); - handler({ - sessionId: parsed[1], - deliverySequence: 1, - generation: 'generation-reconcile', - hostEpoch: 'epoch-reconcile', - durableThrough: messages.length - 1, - fragments, - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - reset: true, - ready: true, - }); - return { - sessionId, - generation: 'generation-reconcile', - hostEpoch: 'epoch-reconcile', - readThroughMessageId: null, - loadBefore: async () => {}, - loadAfter: async () => {}, - loadAround: async () => {}, - close: async () => {}, - }; - }, - }; -} - -test('projects the durable Coordination transcript into the WorkHub conversation', () => { - const messages: StoredMessage[] = [ - { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 10, text: 'What is next?' }, - { - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 11, - text: 'Slice 3 is next.', - modelId: 'test-model', - }, - { - type: 'turn_state', - id: 'state-1', - turnId: 'turn-1', - ts: 12, - status: 'completed', - }, - { - type: 'workhub_coordination', - id: 'assignment-1', - turnId: 'action-1', - ts: 20, - schemaVersion: 1, - kind: 'delegation_assigned', - actionId: 'action-1', - actionFingerprint: `sha256:${'a'.repeat(64)}`, - coordinationTurnId: 'action-1', - targetSessionId: 'payments', - targetSessionName: 'Payments', - targetTurnId: 'payments-turn', - targetMessageId: 'payments-message', - delegationId: 'payments-delegation', - disposition: 'delegate_existing', - userText: 'Continue payments', - }, - ]; - assert.deepEqual(projectWorkHubCoordinationTurns(messages), [{ - messageId: 'user-1', - turnId: 'turn-1', - text: 'What is next?', - result: 'Slice 3 is next.', - state: 'completed', - updatedAt: 11, - }, { - messageId: 'assignment-1', - turnId: 'action-1', - text: 'Continue payments', - state: 'completed', - assignment: { - actionId: 'action-1', - delegationId: 'payments-delegation', - targetSessionId: 'payments', - targetSessionName: 'Payments', - targetMessageId: 'payments-message', - targetTurnId: 'payments-turn', - feedbackState: 'accepted', - linkState: 'active', - }, - updatedAt: 20, - }]); -}); - -test('bounds the visible timeline independently of old delegation linkage', () => { - const assignment: StoredMessage = { - type: 'workhub_coordination', - id: 'assignment-old', - turnId: 'action-old', - ts: 100, - schemaVersion: 1, - kind: 'delegation_assigned', - actionId: 'action-old', - actionFingerprint: `sha256:${'a'.repeat(64)}`, - coordinationTurnId: 'action-old', - targetSessionId: 'payments', - targetSessionName: 'Payments', - targetTurnId: 'payments-turn', - targetMessageId: 'payments-message', - delegationId: 'payments-delegation', - disposition: 'delegate_existing', - userText: 'Continue payments', - }; - const messages: StoredMessage[] = [ - assignment, - ...Array.from({ length: 45 }, (_, index): StoredMessage => ({ - type: 'user', - id: `later-${index}`, - turnId: `later-${index}`, - ts: 100, - text: `Later coordination ${index}`, - })), - ]; - - assert.equal( - projectWorkHubCoordinationTurns(messages).some((turn) => turn.messageId === assignment.id), - false, - ); -}); - -test('projects durable create_new disposition as an explicit new-work announcement', () => { - const assignment: StoredMessage = { - type: 'workhub_coordination', - id: 'assignment-created', - turnId: 'action-created', - ts: 1, - schemaVersion: 1, - kind: 'delegation_assigned', - actionId: 'action-created', - actionFingerprint: `sha256:${'a'.repeat(64)}`, - coordinationTurnId: 'action-created', - targetSessionId: 'login', - targetSessionName: 'Login stability', - targetTurnId: 'login-turn', - targetMessageId: 'login-message', - delegationId: 'login-delegation', - disposition: 'create_new', - userText: 'Fix login stability', - create: { - title: 'Login stability', - workspace: { kind: 'host_path', path: '/workspace' }, - }, - }; - - assert.equal( - projectWorkHubCoordinationTurns([assignment])[0]?.assignment?.createdNew, - true, - ); -}); - -test('a durable replacement abort terminalizes the retired source linkage', () => { - const assignment: StoredMessage = { - type: 'workhub_coordination', - id: 'assignment-old', - turnId: 'action-old', - ts: 1, - schemaVersion: 1, - kind: 'delegation_assigned', - actionId: 'action-old', - actionFingerprint: `sha256:${'a'.repeat(64)}`, - coordinationTurnId: 'action-old', - targetSessionId: 'payments', - targetSessionName: 'Payments', - targetTurnId: 'payments-turn', - targetMessageId: 'payments-message', - delegationId: 'payments-delegation', - disposition: 'delegate_existing', - userText: 'Continue payments', - }; - const aborted: StoredMessage = { - type: 'workhub_coordination', - id: 'replacement-aborted', - turnId: 'replacement-action', - ts: 2, - schemaVersion: 2, - kind: 'delegation_replacement_aborted', - actionId: 'replacement-action', - actionFingerprint: `sha256:${'b'.repeat(64)}`, - coordinationTurnId: 'replacement-action', - abortedActionId: 'action-old', - abortedDelegationId: 'payments-delegation', - targetSessionId: 'login', - reason: 'target_unavailable', - }; - - assert.equal( - projectWorkHubCoordinationTurns([assignment, aborted])[0]?.assignment?.linkState, - 'aborted', - ); -}); - -test('direct-stop projection is retryable until resolved and preserves not_owned links', () => { - const assignment: StoredMessage = { - type: 'workhub_coordination', id: 'assignment', turnId: 'source-action', ts: 1, - schemaVersion: 1, kind: 'delegation_assigned', actionId: 'source-action', - actionFingerprint: `sha256:${'a'.repeat(64)}`, coordinationTurnId: 'source-action', - targetSessionId: 'payments', targetSessionName: 'Payments', targetTurnId: 'payments-turn', - targetMessageId: 'payments-message', delegationId: 'payments-delegation', - disposition: 'delegate_existing', userText: 'Fix payment retry', - }; - const requested: StoredMessage = { - type: 'workhub_coordination', id: 'stop-request', turnId: 'stop-action', ts: 2, - schemaVersion: 3, kind: 'delegation_stop_requested', actionId: 'stop-action', - actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'stop-action', - stopsActionId: 'source-action', stopsDelegationId: 'payments-delegation', - targetSessionId: 'payments', targetMessageId: 'payments-message', - targetSessionName: 'Payments', userText: 'Stop Payments', - }; - const notOwned: StoredMessage = { - type: 'workhub_coordination', id: 'stop-resolution', turnId: 'stop-action', ts: 3, - schemaVersion: 3, kind: 'delegation_stop_resolved', actionId: 'stop-action', - actionFingerprint: `sha256:${'b'.repeat(64)}`, coordinationTurnId: 'stop-action', - stopsActionId: 'source-action', stopsDelegationId: 'payments-delegation', - targetSessionId: 'payments', targetTurnId: 'shared-turn', outcome: 'not_owned', - }; - - assert.equal(projectWorkHubCoordinationTurns([assignment, requested])[1]?.state, 'running'); - const projected = projectWorkHubCoordinationTurns([assignment, requested, notOwned]); - assert.deepEqual(projected[1]?.stop, { - targetSessionId: 'payments', - targetSessionName: 'Payments', - outcome: 'not_owned', - }); - assert.equal(projected[0]?.assignment?.linkState, 'active'); - - const stopped = { ...notOwned, outcome: 'stop_delivered' as const }; - assert.equal( - projectWorkHubCoordinationTurns([assignment, requested, stopped])[0]?.assignment?.linkState, - 'stopped', - ); -}); - -test('durable supersession terminalizes only the replaced linkage', () => { - const source: StoredMessage = { - type: 'workhub_coordination', - id: 'assignment-old', - turnId: 'action-old', - ts: 1, - schemaVersion: 1, - kind: 'delegation_assigned', - actionId: 'action-old', - actionFingerprint: `sha256:${'a'.repeat(64)}`, - coordinationTurnId: 'action-old', - targetSessionId: 'payments', - targetSessionName: 'Payments', - targetTurnId: 'payments-turn', - targetMessageId: 'payments-message', - delegationId: 'payments-delegation', - disposition: 'delegate_existing', - userText: 'Continue payments', - }; - const replacement: StoredMessage = { - ...source, - id: 'assignment-new', - turnId: 'action-new', - ts: 2, - schemaVersion: 2, - actionId: 'action-new', - actionFingerprint: `sha256:${'b'.repeat(64)}`, - coordinationTurnId: 'action-new', - targetSessionId: 'login', - targetSessionName: 'Login', - targetTurnId: 'login-turn', - targetMessageId: 'login-message', - delegationId: 'login-delegation', - userText: 'Switch to login', - replacesActionId: 'action-old', - replacesDelegationId: 'payments-delegation', - }; - const superseded: StoredMessage = { - type: 'workhub_coordination', - id: 'superseded-old', - turnId: 'action-new', - ts: 3, - schemaVersion: 2, - kind: 'delegation_superseded', - actionId: 'action-new', - actionFingerprint: `sha256:${'b'.repeat(64)}`, - coordinationTurnId: 'action-new', - supersededActionId: 'action-old', - supersededDelegationId: 'payments-delegation', - replacementDelegationId: 'login-delegation', - }; - const messages = [source, replacement, superseded]; - - assert.deepEqual( - projectWorkHubCoordinationTurns(messages).map((turn) => turn.assignment?.linkState), - ['superseded', 'active'], - ); -}); - -test('Coordination transcript adapter never replays history and completes only the latest record', async () => { - const sessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'coordination' }); - const snapshots: unknown[] = []; - let closes = 0; - const latestLoads: Array<{ sequence: number | null; maxBytes: number | undefined; intent: string | undefined }> = []; - let deliver: ((batch: DesktopTranscriptBatch) => void) | undefined; - const adapter = createDesktopWorkHubCoordinationPort({ - sessionId, - transcripts: { - open: async (requestedSessionId, handler) => { - assert.equal(requestedSessionId, sessionId); - deliver = handler; - handler({ - sessionId: 'coordination', - deliverySequence: 1, - generation: 'generation-1', - hostEpoch: 'epoch-1', - durableThrough: null, - fragments: [], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: true, - hasNewer: false, - reset: true, - ready: true, - }); - return { - sessionId, - generation: 'generation-1', - hostEpoch: 'epoch-1', - readThroughMessageId: null, - loadBefore: async () => assert.fail('conversation open must not replay older history'), - loadAfter: async () => assert.fail('conversation open must not replay newer history'), - loadAround: async (sequence, maxBytes, navigation) => { - latestLoads.push({ sequence, maxBytes, intent: navigation?.intent }); - const message: StoredMessage = { - type: 'user', - id: 'latest-message', - turnId: 'latest-turn', - ts: 7, - text: 'Latest bounded WorkHub record', - }; - const data = new TextEncoder().encode(JSON.stringify(message)); - handler({ - sessionId: 'coordination', - navigationVersion: navigation?.navigationVersion, - deliverySequence: 3, - generation: 'generation-2', - hostEpoch: 'epoch-1', - durableThrough: 7, - fragments: [ - { - source: 'durable', - identity: 7, - order: null, - byteOffset: 0, - totalBytes: data.byteLength, - data, - }, - ], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: true, - hasNewer: false, - reset: true, - ready: true, - }); - }, - close: async () => { closes += 1; }, - }; - }, - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => assert.fail('conversation open must not read route candidates'), - act: async () => ({ - ok: true, - result: { - disposition: 'answer_here', - coordinationTurnId: 'coordination-turn', - }, - }), - }); - - const handle = await adapter.open( - (turns) => snapshots.push(turns), - (error) => assert.fail(String(error)), - ); - assert.deepEqual(snapshots, [[]]); - deliver?.({ - sessionId: 'coordination', - deliverySequence: 2, - generation: 'generation-2', - hostEpoch: 'epoch-1', - durableThrough: 7, - fragments: [], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: true, - hasNewer: false, - reset: true, - ready: true, - }); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(latestLoads, [{ sequence: null, maxBytes: 512 * 1024, intent: 'followTail' }]); - assert.deepEqual(snapshots, [[], [ - { - messageId: 'latest-message', - turnId: 'latest-turn', - text: 'Latest bounded WorkHub record', - state: 'completed', - updatedAt: 7, - }, - ]]); - await handle.close(); - assert.equal(closes, 1); -}); - -test('Coordination transcript adapter retries latest-record completion in the same generation', async () => { - const sessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'coordination' }); - const errors: unknown[] = []; - let latestLoads = 0; - let deliver: ((batch: DesktopTranscriptBatch) => void) | undefined; - const adapter = createDesktopWorkHubCoordinationPort({ - sessionId, - transcripts: { - open: async (_requestedSessionId, handler) => { - deliver = handler; - handler({ - sessionId: 'coordination', - deliverySequence: 1, - generation: 'generation-1', - hostEpoch: 'epoch-1', - durableThrough: 7, - fragments: [], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: true, - hasNewer: false, - reset: true, - ready: true, - }); - return { - sessionId, - generation: 'generation-1', - hostEpoch: 'epoch-1', - readThroughMessageId: null, - loadBefore: async () => assert.fail('conversation open must not replay older history'), - loadAround: async () => { - latestLoads += 1; - if (latestLoads === 1) throw new Error('transient latest-record read failure'); - }, - loadAfter: async () => {}, - close: async () => {}, - }; - }, - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => assert.fail('conversation open must not read route candidates'), - act: async () => ({ - ok: true, - result: { - disposition: 'answer_here', - coordinationTurnId: 'coordination-turn', - }, - }), - }); - - const handle = await adapter.open(() => {}, (error) => errors.push(error)); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(latestLoads, 1); - assert.equal(errors.length, 1); - - deliver?.({ - sessionId: 'coordination', - deliverySequence: 2, - navigationVersion: 1, - generation: 'generation-1', - hostEpoch: 'epoch-1', - durableThrough: 7, - fragments: [], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: true, - hasNewer: false, - reset: false, - ready: true, - }); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(latestLoads, 2); - - deliver?.({ - sessionId: 'coordination', - deliverySequence: 3, - navigationVersion: 2, - generation: 'generation-1', - hostEpoch: 'epoch-1', - durableThrough: 7, - fragments: [], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: true, - hasNewer: false, - reset: true, - ready: true, - }); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(latestLoads, 3); - await handle.close(); -}); - -test('Coordination transcript adapter ignores a stale latest-record failure after reset', async () => { - const sessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'coordination' }); - const errors: unknown[] = []; - let latestLoads = 0; - let rejectStaleLoad: ((error: Error) => void) | undefined; - let deliver: ((batch: DesktopTranscriptBatch) => void) | undefined; - const adapter = createDesktopWorkHubCoordinationPort({ - sessionId, - transcripts: { - open: async (_requestedSessionId, handler) => { - deliver = handler; - handler({ - sessionId: 'coordination', - deliverySequence: 1, - generation: 'generation-1', - hostEpoch: 'epoch-1', - durableThrough: 7, - fragments: [], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: true, - hasNewer: false, - reset: true, - ready: true, - }); - return { - sessionId, - generation: 'generation-1', - hostEpoch: 'epoch-1', - readThroughMessageId: null, - loadBefore: async () => assert.fail('conversation open must not replay older history'), - loadAfter: async () => assert.fail('conversation open must not replay newer history'), - loadAround: async () => { - latestLoads += 1; - if (latestLoads === 1) { - await new Promise((_resolve, reject) => { - rejectStaleLoad = reject; - }); - } - }, - close: async () => {}, - }; - }, - }, - record: async (input) => ({ turnId: input.turnId }), - candidates: async () => assert.fail('conversation open must not read route candidates'), - act: async () => ({ - ok: true, - result: { - disposition: 'answer_here', - coordinationTurnId: 'coordination-turn', - }, - }), - }); - - const handle = await adapter.open(() => {}, (error) => errors.push(error)); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(latestLoads, 1); - deliver?.({ - sessionId: 'coordination', - deliverySequence: 2, - navigationVersion: 1, - generation: 'generation-2', - hostEpoch: 'epoch-1', - durableThrough: 7, - fragments: [], - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: true, - hasNewer: false, - reset: true, - ready: true, - }); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(latestLoads, 2); - rejectStaleLoad?.(new Error('stale latest-record failure')); - await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(errors, []); - await handle.close(); -}); - -test('projects durable Session messages into an ordered WorkHub conversation', () => { - const turns = projectWorkHubSessionTurns({ - target: { sessionId: 'payment' }, - messages: [ - { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 10, text: '检查重复投递' }, - { - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 11, - text: '已定位风险', - modelId: 'test-model', - }, - { type: 'user', id: 'user-2', turnId: 'turn-1', ts: 12, text: '再补充测试点' }, - { - type: 'assistant', - id: 'assistant-2', - turnId: 'turn-1', - ts: 13, - text: '已补充测试点', - modelId: 'test-model', - }, - { - type: 'turn_state', - id: 'state-1', - turnId: 'turn-1', - ts: 14, - status: 'completed', - }, - ], - }); - - assert.deepEqual(turns, [ - { - messageId: 'user-1', - target: { sessionId: 'payment' }, - turnId: 'turn-1', - text: '检查重复投递', - state: 'completed', - result: '已定位风险', - updatedAt: 10, - }, - { - messageId: 'user-2', - target: { sessionId: 'payment' }, - turnId: 'turn-1', - text: '再补充测试点', - state: 'completed', - result: '已补充测试点', - updatedAt: 12, - }, - ]); -}); - -test('desktop adapter rebuilds recent turns from the Session transcript and closes the read', async () => { - const sessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'payment' }); - const messages: StoredMessage[] = [ - { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 10, text: '检查重复投递' }, - { - type: 'assistant', - id: 'assistant-1', - turnId: 'turn-1', - ts: 11, - text: '已定位风险', - modelId: 'test-model', - }, - { - type: 'turn_state', - id: 'state-1', - turnId: 'turn-1', - ts: 12, - status: 'completed', - }, - ]; - let closes = 0; - const adapter = createDesktopWorkHubSessionPort({ - sessions: { - list: async () => [], - listTurns: async () => [], - queryMessageExecutions: noMessageExecutions, - create: async () => { - throw new Error('not used'); - }, - send: async () => { - throw new Error('not used'); - }, - stop: async () => {}, - subscribeChanges: () => () => {}, - }, - transcripts: { - open: async (requestedSessionId, handler) => { - assert.equal(requestedSessionId, sessionId); - const fragments = messages.map((message, sequence) => { - const data = new TextEncoder().encode(JSON.stringify(message)); - return { - source: 'durable' as const, - identity: sequence, - order: null, - byteOffset: 0, - totalBytes: data.byteLength, - data, - }; - }); - handler({ - sessionId: 'payment', - deliverySequence: 1, - generation: 'generation-1', - hostEpoch: 'epoch-1', - durableThrough: 2, - fragments, - evictedDurableSequences: [], - completedOverlayMessageIds: [], - hasOlder: false, - hasNewer: false, - reset: true, - ready: true, - } satisfies DesktopTranscriptBatch); - return { - sessionId, - generation: 'generation-1', - hostEpoch: 'epoch-1', - readThroughMessageId: null, - loadBefore: async () => {}, - loadAfter: async () => {}, - loadAround: async () => {}, - close: async () => { - closes += 1; - }, - }; - }, - }, - projectName: () => 'Maka', - }); - - assert.deepEqual(await adapter.recentTurns([{ sessionId }]), [{ - messageId: 'user-1', - target: { sessionId }, - turnId: 'turn-1', - text: '检查重复投递', - state: 'completed', - result: '已定位风险', - updatedAt: 10, - }]); - assert.equal(closes, 1); -}); - -test('desktop adapter cancels an unavailable transcript without hiding ready Sessions', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const unavailableId = desktopSessionKey({ hostId: 'local-host', sessionId: 'unavailable' }); - const readyId = desktopSessionKey({ hostId: 'local-host', sessionId: 'ready' }); - let cancellations = 0; - const adapter = createDesktopWorkHubSessionPort({ - sessions: { - list: async () => [], - listTurns: async () => [], - queryMessageExecutions: noMessageExecutions, - create: async () => { throw new Error('not used'); }, - send: async () => { throw new Error('not used'); }, - stop: async () => {}, - subscribeChanges: () => () => {}, - }, - transcripts: { - open: async (sessionId, handler, registerCancellation) => { - if (sessionId === unavailableId) { - return await new Promise((_resolve, reject) => { - registerCancellation?.(() => { - cancellations += 1; - reject(new Error('cancelled unavailable transcript')); - }); - }); - } - const message: StoredMessage = { - type: 'user', id: 'user-ready', turnId: 'turn-ready', ts: 10, text: '可用工作', - }; - const data = new TextEncoder().encode(JSON.stringify(message)); - handler({ - sessionId: 'ready', deliverySequence: 1, generation: 'generation-ready', - hostEpoch: 'epoch-ready', durableThrough: 0, - fragments: [{ - source: 'durable', identity: 0, order: null, byteOffset: 0, - totalBytes: data.byteLength, data, - }], - evictedDurableSequences: [], completedOverlayMessageIds: [], - hasOlder: false, hasNewer: false, reset: true, ready: true, - }); - return { - sessionId: readyId, generation: 'generation-ready', hostEpoch: 'epoch-ready', - readThroughMessageId: null, loadBefore: async () => {}, loadAfter: async () => {}, loadAround: async () => {}, - close: async () => {}, - }; - }, - }, - projectName: () => 'Maka', - }); - - const turns = adapter.recentTurns([ - { sessionId: unavailableId }, - { sessionId: readyId }, - ]); - await Promise.resolve(); - t.mock.timers.tick(5_000); - - assert.deepEqual(await turns, [{ - messageId: 'user-ready', target: { sessionId: readyId }, turnId: 'turn-ready', - text: '可用工作', state: 'completed', updatedAt: 10, - }]); - assert.equal(cancellations, 1); -}); - -test('desktop adapter projects Session catalog facts without owning copies', async () => { - const source = [ - desktopSession('ordinary', { - name: '支付回调幂等性', - status: 'running', - runningTurnIds: ['turn-running'], - lastMessageAt: 30, - lastMessagePreview: '正在补充重复投递测试', - }), - desktopSession('side', { - labels: ['mode:side_conversation'], - lastMessageAt: 20, - }), - desktopSession('waiting', { - status: 'waiting_for_user', - runningTurnIds: ['turn-waiting'], - lastMessageAt: 15, - }), - desktopSession('child', { - subagent: {}, - lastMessageAt: 10, - }), - ]; - const adapter = createDesktopWorkHubSessionPort({ - transcripts: unusedTranscripts, - sessions: { - list: async () => source, - listTurns: async () => [], - queryMessageExecutions: noMessageExecutions, - create: async () => { - throw new Error('not used'); - }, - send: async () => { - throw new Error('not used'); - }, - stop: async () => {}, - subscribeChanges: () => () => {}, - }, - projectName: (projectId) => projectId === 'project-maka' ? 'Maka' : undefined, - }); - - assert.deepEqual(await adapter.list(), [ - { - target: { sessionId: 'ordinary' }, - projectName: 'Maka', - sessionName: '支付回调幂等性', - kind: 'ordinary', - archived: false, - state: 'running', - runningTurnIds: ['turn-running'], - latestResult: '正在补充重复投递测试', - updatedAt: 30, - }, - { - target: { sessionId: 'side' }, - projectName: 'Maka', - sessionName: 'side', - kind: 'internal', - archived: false, - state: 'active', - runningTurnIds: [], - updatedAt: 20, - }, - { - target: { sessionId: 'waiting' }, - projectName: 'Maka', - sessionName: 'waiting', - kind: 'ordinary', - archived: false, - state: 'waiting_for_user', - runningTurnIds: ['turn-waiting'], - updatedAt: 15, - }, - { - target: { sessionId: 'child' }, - projectName: 'Maka', - sessionName: 'child', - kind: 'subagent', - archived: false, - state: 'active', - runningTurnIds: [], - updatedAt: 10, - }, - ]); -}); - -test('desktop adapter rebuilds delegation feedback from the Message-owned execution Turn', async () => { - const sessions = [ - desktopSession('accepted'), - desktopSession('running', { status: 'running', runningTurnIds: ['turn-running'] }), - desktopSession('stale-running'), - desktopSession('recorded-running-only', { runningTurnIds: undefined }), - desktopSession('waiting', { - status: 'waiting_for_user', - runningTurnIds: ['turn-waiting'], - }), - desktopSession('completed', { - status: 'waiting_for_user', - runningTurnIds: ['later-turn'], - }), - desktopSession('failed'), - desktopSession('aborted'), - desktopSession('cancelled'), - desktopSession('recovering'), - ]; - const turns = new Map>([ - ['running', [{ turnId: 'turn-running', status: 'running', statusSource: 'recorded' }]], - ['stale-running', [{ - turnId: 'turn-stale-running', - status: 'running', - statusSource: 'recorded', - }]], - ['recorded-running-only', [{ - turnId: 'turn-recorded-running-only', - status: 'running', - statusSource: 'recorded', - }]], - ['waiting', [{ turnId: 'turn-waiting', status: 'running', statusSource: 'recorded' }]], - ['completed', [{ turnId: 'turn-completed', status: 'completed', statusSource: 'recorded' }]], - ['failed', [{ turnId: 'turn-failed', status: 'failed', statusSource: 'recorded' }]], - ['aborted', [{ turnId: 'turn-aborted', status: 'aborted', statusSource: 'recorded' }]], - ]); - const adapter = createDesktopWorkHubSessionPort({ - transcripts: unusedTranscripts, - sessions: { - list: async () => sessions, - listTurns: async (sessionId) => { - if (sessionId === 'recovering') throw new Error('Host is recovering'); - return turns.get(sessionId) ?? []; - }, - queryMessageExecutions: async (sessionId, messageIds) => ({ - resolutions: sessionId === 'accepted' - ? messageIds.map((messageId) => ({ messageId, state: 'pending' as const })) - : sessionId === 'cancelled' - ? messageIds.map((messageId) => ({ messageId, state: 'cancelled' as const })) - : sessionId === 'recovering' - ? [] - : messageIds.map((messageId) => ({ - messageId, - state: 'owned' as const, - turnId: `turn-${sessionId}`, - runId: `run-${sessionId}`, - })), - }), - create: async () => { throw new Error('not used'); }, - send: async () => { throw new Error('not used'); }, - stop: async () => {}, - subscribeChanges: () => () => {}, - }, - projectName: () => 'Maka', - }); - const references = [ - ['accepted', 'turn-accepted'], - ['running', 'turn-running'], - ['stale-running', 'turn-stale-running'], - ['recorded-running-only', 'turn-recorded-running-only'], - ['waiting', 'turn-waiting'], - ['completed', 'turn-completed'], - ['failed', 'turn-failed'], - ['aborted', 'turn-aborted'], - ['cancelled', 'turn-cancelled'], - ['recovering', 'turn-recovering'], - ].map(([targetSessionId, targetTurnId]) => ({ - delegationId: `delegation-${targetSessionId}`, - targetSessionId: targetSessionId!, - targetMessageId: `message-${targetSessionId}`, - targetTurnId: targetTurnId!, - })); - - const feedback = await adapter.delegationFeedback(references); - - assert.deepEqual(feedback.map(({ delegationId, state }) => ({ delegationId, state })), [ - { delegationId: 'delegation-accepted', state: 'accepted' }, - { delegationId: 'delegation-running', state: 'running' }, - { delegationId: 'delegation-stale-running', state: 'accepted' }, - { delegationId: 'delegation-recorded-running-only', state: 'running' }, - { delegationId: 'delegation-waiting', state: 'waiting_for_user' }, - { delegationId: 'delegation-completed', state: 'completed' }, - { delegationId: 'delegation-failed', state: 'failed' }, - { delegationId: 'delegation-aborted', state: 'aborted' }, - { delegationId: 'delegation-cancelled', state: 'aborted' }, - { delegationId: 'delegation-recovering', state: 'recovering' }, - ]); -}); - -test('desktop adapter follows a delegated Message into its successor Turn', async () => { - const targetSessionId = desktopSessionKey({ hostId: 'local-host', sessionId: 'payments' }); - const adapter = createDesktopWorkHubSessionPort({ - transcripts: transcriptsWith([{ - type: 'user', - id: 'payment-message', - turnId: 'successor-turn', - ts: 2, - text: 'Continue payment recovery', - steeringEventId: 'payment-message', - }]), - sessions: { - list: async () => [desktopSession(targetSessionId, { - status: 'running', - runningTurnIds: ['successor-turn'], - })], - listTurns: async () => [ - { - turnId: 'admission-turn', - status: 'completed', - statusSource: 'recorded', - }, - { - turnId: 'successor-turn', - status: 'running', - statusSource: 'recorded', - }, - ], - queryMessageExecutions: async (_sessionId, messageIds) => ({ - resolutions: messageIds.map((messageId) => ({ - messageId, - state: 'owned' as const, - turnId: 'successor-turn', - runId: 'successor-run', - })), - }), - create: async () => { throw new Error('not used'); }, - send: async () => { throw new Error('not used'); }, - stop: async () => {}, - subscribeChanges: () => () => {}, - }, - projectName: () => 'Maka', - }); - - const references = [{ - delegationId: 'payment-delegation', - targetSessionId, - targetTurnId: 'admission-turn', - targetMessageId: 'payment-message', - }]; - assert.deepEqual(await adapter.delegationFeedback(references), [{ - delegationId: 'payment-delegation', - state: 'running', - }]); -}); - -test('desktop adapter derives stable origin evidence from the existing Session log', async () => { - let reads = 0; - const adapter = createDesktopWorkHubSessionPort({ - transcripts: unusedTranscripts, - sessions: { - list: async () => [], - listTurns: async (sessionId) => { - reads += 1; - assert.equal(sessionId, 'payment'); - return [ - { userPromptPreview: '检查支付回调重复投递时的幂等性' }, - { userPromptPreview: '把风险按高、中、低分组' }, - ]; - }, - queryMessageExecutions: noMessageExecutions, - create: async () => { - throw new Error('not used'); - }, - send: async () => { - throw new Error('not used'); - }, - stop: async () => {}, - subscribeChanges: () => () => {}, - }, - projectName: () => 'Maka', - }); - - const first = await adapter.routingEvidence([{ sessionId: 'payment' }]); - const second = await adapter.routingEvidence([{ sessionId: 'payment' }]); - - assert.deepEqual(first, [{ - target: { sessionId: 'payment' }, - originPrompt: '检查支付回调重复投递时的幂等性', - }]); - assert.deepEqual(second, first); - assert.equal(reads, 1); -}); diff --git a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts b/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts deleted file mode 100644 index 893ba8db9b..0000000000 --- a/apps/desktop/src/main/__tests__/workhub-session-resolver-port.test.ts +++ /dev/null @@ -1,137 +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 assert from 'node:assert/strict'; -import test from 'node:test'; -import type { - WorkHubSessionResolution, - WorkHubSessionResolver, -} from '../../renderer/application/contracts/workhub-request-intent.js'; -import { createWorkHubRoutePolicy } from '../../renderer/features/workhub/index.js'; - -const routable = (sessionId: string, sessionName: string) => ({ - target: { sessionId }, - projectName: 'demo', - sessionName, - updatedAt: 1, -}); - -/** Stands in for the Host read the stop policy makes once a reference resolves. */ -/** - * A stand-in for a later ranked resolver. It recalls by remembered description - * rather than display name, which is exactly the recall the exact-name baseline - * cannot do, and it answers in the same contract. - */ -const describedResolver = ( - descriptions: ReadonlyMap, -): WorkHubSessionResolver => ({ - resolve({ reference, sessions }): WorkHubSessionResolution { - const candidates = sessions - .filter((session) => descriptions.get(session.ref) === reference.text) - .map((session) => ({ - ref: session.ref, - evidence: { kind: 'named' as const, remainder: '' }, - })); - const [first, ...rest] = candidates; - if (!first) return { kind: 'none' }; - if (rest.length > 0) return { kind: 'ambiguous', candidates }; - return { kind: 'ranked', candidates: [first] }; - }, -}); - -test('stop resolves through the shared port rather than a stop-specific grammar', async () => { - const sessions = [routable('payments', 'Payments'), routable('login', 'Login')]; - - // Action Intent extracts the reference ("Stop the payment timeout work" -> - // "payment timeout work"); resolving it is the Resolver's business alone. - // The exact-name baseline recalls the display name and nothing else. - const baseline = createWorkHubRoutePolicy(); - assert.deepEqual(baseline.resolveStop({ text: 'Stop Payments', sessions}), { - kind: 'target', - target: { sessionId: 'payments' }, - }); - assert.deepEqual( - baseline.resolveStop({ - text: 'Stop the payment timeout work', - sessions, - }), - { kind: 'not_requested' }, - ); - - // Swapping the resolver changes only recall. The decision the stop policy - // produces keeps the same opaque identities and the same durable protocol. - const ranked = createWorkHubRoutePolicy( - describedResolver(new Map([['payments', 'payment timeout work']])), - ); - assert.deepEqual( - ranked.resolveStop({ - text: 'Stop the payment timeout work', - sessions, - }), { - kind: 'target', - target: { sessionId: 'payments' }, - }); -}); - -test('an ambiguous recall never becomes a destructive target', async () => { - const resolver = describedResolver( - new Map([ - ['payments', 'payment timeout work'], - ['payments-eu', 'payment timeout work'], - ]), - ); - assert.deepEqual( - createWorkHubRoutePolicy(resolver).resolveStop({ - text: 'Stop the payment timeout work', - sessions: [routable('payments', 'Payments'), routable('payments-eu', 'Payments EU')], - }), - { kind: 'clarification', reason: 'stop_target_ambiguous' }, - ); -}); - -test('a resolver cannot widen stop beyond the visible candidate set it was given', async () => { - const resolver: WorkHubSessionResolver = { - resolve: () => ({ - kind: 'ranked', - candidates: [ - { ref: 'never-offered', evidence: { kind: 'named', remainder: '' } }, - ], - }), - }; - assert.deepEqual( - createWorkHubRoutePolicy(resolver).resolveStop({ - text: 'Stop Payments', - sessions: [routable('payments', 'Payments')], - }), - { kind: 'not_requested' }, - ); -}); - -test('a stop cue with no safe reference asks for one instead of resolving', async () => { - const resolver: WorkHubSessionResolver = { - resolve: () => assert.fail('an unsafe reference must not reach the Session Resolver'), - }; - assert.deepEqual( - createWorkHubRoutePolicy(resolver).resolveStop({ - text: 'Stop it', - sessions: [routable('payments', 'Payments')], - }), - { kind: 'clarification', reason: 'stop_target_required' }, - ); -}); diff --git a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts b/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts deleted file mode 100644 index 0644ad6553..0000000000 --- a/apps/desktop/src/main/__tests__/workhub-surface-flow.test.ts +++ /dev/null @@ -1,982 +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 assert from 'node:assert/strict'; -import test from 'node:test'; -import { createElement } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; -import { - WorkHubCoordinationStatus, - WorkHubCoordinationTurnView, - WorkHubProjectionRefreshGate, - WorkHubSurfaceRouteGate, - submitAndRecordWorkHubSurfaceInput, - submitLeasedWorkHubSurfaceInput, - submitWorkHubSurfaceInput, - visibleWorkHubConversation, - workHubAmbiguousCommandPrompt, - workHubCoordinationSummary, - workHubSurfaceFailure, - workHubSubmissionClearsDraft, -} from '../../renderer/workhub-surface.js'; -import { - createWorkHubController, - WORKHUB_ROUTING_STRATEGY_ID, - type WorkHubController, - type WorkHubCoordinationTurn, - type WorkHubDelegationExecutionState, - type WorkHubSubmitInput, -} from '../../renderer/workhub-controller.js'; -import { ExpectedOperationError } from '../../renderer/application/contracts/operation-diagnostics.js'; -import { WorkHubCoordinationFailure } from '../../renderer/workhub-coordination-port.js'; -import { WorkHubSendLease } from '../../renderer/workhub-send-lease.js'; -import { getWorkHubCopy } from '../../renderer/locales/workhub-copy.js'; -import { - createDesktopWorkHubSessionPort, - type WorkHubDesktopSession, -} from '../../renderer/workhub-session-port.js'; - -test('surface turns Action Gate rejections into safe actionable failures', (context) => { - context.mock.method(console, 'error', () => undefined); - assert.equal( - workHubSurfaceFailure(new ExpectedOperationError('candidates_changed')), - 'candidates_changed', - ); - assert.equal( - workHubSurfaceFailure(new ExpectedOperationError('linked_correction_unavailable')), - 'linked_correction_unavailable', - ); - assert.equal( - workHubSurfaceFailure(new WorkHubCoordinationFailure('session_busy', 'Host diagnostic')), - 'target_waiting', - ); - assert.equal( - workHubSurfaceFailure(new WorkHubCoordinationFailure('operation_conflict', 'Host diagnostic')), - 'action_changed', - ); - assert.equal( - workHubSurfaceFailure(new Error('WorkHub Session candidates changed; private detail')), - 'delivery_failed', - ); -}); - -test('surface route gate rejects same-frame duplicate operations and reopens after settle', async () => { - const gate = new WorkHubSurfaceRouteGate(); - let release: (() => void) | undefined; - const first = gate.run(async () => { - await new Promise((resolve) => { - release = resolve; - }); - return 'first'; - }); - - assert.equal(gate.pending, true); - assert.equal(await gate.run(async () => 'duplicate'), undefined); - release?.(); - assert.equal(await first, 'first'); - assert.equal(gate.pending, false); - assert.equal(await gate.run(async () => 'next'), 'next'); -}); - -test('Coordination lifecycle keeps a visible loading state and exposes failure recovery', () => { - const renderStatus = (state: 'resolving' | 'failed') => - renderToStaticMarkup( - createElement(LocaleProvider, { - locale: 'en', - children: createElement(AstryxLocaleProvider, { - children: createElement(WorkHubCoordinationStatus, { - locale: 'en', - state, - onRetry: () => undefined, - }), - }), - }), - ); - const resolving = renderStatus('resolving'); - const failed = renderStatus('failed'); - - assert.match(resolving, /Preparing WorkHub/); - assert.match(resolving, /aria-busy="true"/); - assert.match(failed, /role="alert"/); - assert.match(failed, /Check the default model/); - assert.match(failed, />Retry { - const states: Array<[WorkHubDelegationExecutionState, string]> = [ - ['accepted', 'Accepted'], - ['running', 'Running'], - ['waiting_for_user', 'Waiting for you'], - ['completed', 'Completed'], - ['failed', 'Failed'], - ['aborted', 'Aborted'], - ['recovering', 'Recovering'], - ]; - for (const [state, label] of states) { - const turn: WorkHubCoordinationTurn = { - messageId: 'assignment-1', - turnId: 'action-1', - text: 'Continue payments', - state: 'completed', - assignment: { - actionId: 'action-1', - delegationId: 'delegation-1', - targetSessionId: 'payment', - targetSessionName: 'Payments', - targetMessageId: 'payment-message', - targetTurnId: 'payment-turn', - feedbackState: state, - linkState: 'active', - }, - updatedAt: 10, - }; - const markup = renderToStaticMarkup( - createElement(LocaleProvider, { - locale: 'en', - children: createElement(AstryxLocaleProvider, { - children: createElement(WorkHubCoordinationTurnView, { - turn, - projection: { sessions: [], turns: [] }, - locale: 'en', - onOpenSession: () => undefined, - }), - }), - }), - ); - assert.match(markup, / - ))} - - ) : null} - - ) : turn.outcome?.kind === 'discussion' ? ( - <> -

{copy.discussionStayed}

- {copy.discussionHint} - - ) : turn.outcome?.kind === 'waiting' ? ( -
-

{copy.waitingForDecision}

- {copy.requestNotSent} -
- ) : stopped ? ( - session.target.sessionId === stopped.target.sessionId, - )} - targetSessionId={stopped.target.sessionId} - heading={copy.stopOutcomes[stopped.outcome]} - state={stopped.outcome === 'not_owned' ? copy.openSessionToStop : copy.stopRecorded} - result={undefined} - copy={copy} - onOpenSession={props.onOpenSession} - /> - ) : resumed ? ( - session.target.sessionId === resumed.target.sessionId, - )} - targetSessionId={resumed.target.sessionId} - heading={copy.resumeOutcomes[resumed.outcome]} - state="" - result={undefined} - copy={copy} - onOpenSession={props.onOpenSession} - /> - ) : submitted ? ( - - ) : null} - - ); -} - -function WorkHubMessageFrame(props: { - anchorId: string; - text: string; - attachments?: AttachmentRef[]; - state: string; - linkState?: WorkHubDelegationLinkState; - projected?: boolean; - work?: { sessionId: string; name: string; projectName?: string }; - onOpenSession?(sessionId: string): void; - children: ReactNode; -}) { - const highlight = useContext(WorkHubHighlightContext); - const work = props.work; - const rail = work ? ( -