From 3752577233288cccae806eaec506db7ec0a1b567 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 12:55:58 -0700 Subject: [PATCH 1/4] fix(mothership): cancel desktop tools a signed-out turn starts late, guard the shown-once license key - A turn's stream now binds to the session it started in. Its tool events can arrive after sign-out stops every desktop tool, and each one then gets an already-aborted lease instead of a fresh controller. A turn started after sign-in runs normally. - The generated license key counts as an unsaved change, so leaving the Licenses tab asks first, and confirming drops it. --- .../home/hooks/desktop-tool-lifetimes.test.ts | 48 +++++-- .../home/hooks/desktop-tool-lifetimes.ts | 28 +++- .../[workspaceId]/home/hooks/use-chat.ts | 17 +-- .../components/mothership/mothership.test.tsx | 131 ++++++++++++++++++ apps/sim/stores/index.test.ts | 6 +- 5 files changed, 202 insertions(+), 28 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts index 9cf43b508c9..b768a0be114 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest' import { - leaseDesktopTool, + desktopToolTurn, stopAllDesktopTools, stopDesktopTools, } from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes' describe('desktop tool leases', () => { it('cancels every running tool of the stopped turn and no other turn', () => { - const first = leaseDesktopTool('turn-a') - const second = leaseDesktopTool('turn-a') - const other = leaseDesktopTool('turn-b') + const first = desktopToolTurn('turn-a').lease() + const second = desktopToolTurn('turn-a').lease() + const other = desktopToolTurn('turn-b').lease() stopDesktopTools('turn-a', 'user_stop') @@ -21,9 +21,9 @@ describe('desktop tool leases', () => { }) it('keeps a turn reachable by Stop while any of its tools still runs', () => { - const settled = leaseDesktopTool('turn-c') - const running = leaseDesktopTool('turn-c') - for (let turn = 0; turn < 500; turn++) leaseDesktopTool(`busy-${turn}`).release() + const settled = desktopToolTurn('turn-c').lease() + const running = desktopToolTurn('turn-c').lease() + for (let turn = 0; turn < 500; turn++) desktopToolTurn(`busy-${turn}`).lease().release() settled.release() settled.release() @@ -33,11 +33,11 @@ describe('desktop tool leases', () => { }) it('gives a turn whose tools all settled a fresh lifetime for its next tool', () => { - const settled = leaseDesktopTool('turn-d') + const settled = desktopToolTurn('turn-d').lease() settled.release() stopDesktopTools('turn-d', 'user_stop') - const next = leaseDesktopTool('turn-d') + const next = desktopToolTurn('turn-d').lease() expect(settled.signal.aborted).toBe(false) expect(next.signal).not.toBe(settled.signal) @@ -46,9 +46,9 @@ describe('desktop tool leases', () => { }) it('does not let a tool that settles after Stop release a newer lease on the turn', () => { - const stopped = leaseDesktopTool('turn-e') + const stopped = desktopToolTurn('turn-e').lease() stopDesktopTools('turn-e', 'user_stop') - const next = leaseDesktopTool('turn-e') + const next = desktopToolTurn('turn-e').lease() stopped.release() stopDesktopTools('turn-e', 'user_stop') @@ -57,17 +57,35 @@ describe('desktop tool leases', () => { }) it('cancels the running tools of every turn when the session ends', () => { - const first = leaseDesktopTool('turn-f') - const second = leaseDesktopTool('turn-g') + const first = desktopToolTurn('turn-f').lease() + const second = desktopToolTurn('turn-g').lease() stopAllDesktopTools('signed_out') - const next = leaseDesktopTool('turn-f') expect(first.signal.aborted).toBe(true) expect(second.signal.aborted).toBe(true) expect(first.signal.reason).toBe('signed_out') - expect(next.signal.aborted).toBe(false) first.release() + second.release() + }) + + it('cancels a tool a turn of the ended session starts after sign-out', () => { + const turn = desktopToolTurn('turn-h') + stopAllDesktopTools('signed_out') + + const late = turn.lease() + + expect(late.signal.aborted).toBe(true) + expect(late.signal.reason).toBe('signed_out') + late.release() + }) + + it('runs the tools of a turn started after the session ended', () => { + stopAllDesktopTools('signed_out') + + const next = desktopToolTurn('turn-i').lease() + + expect(next.signal.aborted).toBe(false) next.release() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts index 9e2f49aeee1..75c7e48fd3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts @@ -15,6 +15,9 @@ interface RunningTurnTools { */ const runningTurns = new Map() +/** Aborted by `stopAllDesktopTools`, then replaced, so each signed-in session has its own. */ +let session = new AbortController() + /** A running desktop tool's hold on its turn. */ interface DesktopToolLease { /** Aborted only by the user's Stop of the turn, or by signing out. */ @@ -23,13 +26,31 @@ interface DesktopToolLease { release(): void } +/** A turn's stream, bound to the session it started in. */ +export interface DesktopToolTurn { + /** Starts one desktop tool for the turn. */ + lease(): DesktopToolLease +} + +/** + * Binds a turn's stream to the current session. Take it once, when the stream starts: its tool + * events can still arrive after a sign-out, and each of them then gets an already-aborted lease. + */ +export function desktopToolTurn(streamId: string): DesktopToolTurn { + const startedIn = session.signal + return { + lease: () => + startedIn.aborted ? { signal: startedIn, release() {} } : leaseDesktopTool(streamId), + } +} + /** * Starts a desktop tool (a browser action, a local file read or import) for a turn. Only the * user's Stop of that turn, or signing out (`stopAllDesktopTools`), cancels it: replacing the * stream reader, leaving the chat view, or stopping another chat's turn leaves it running to * finish and report its own result. */ -export function leaseDesktopTool(streamId: string): DesktopToolLease { +function leaseDesktopTool(streamId: string): DesktopToolLease { let turn = runningTurns.get(streamId) if (!turn) { turn = { stop: new AbortController(), running: 0 } @@ -57,9 +78,12 @@ export function stopDesktopTools(streamId: string, reason: string): void { /** * Cancels every leased desktop tool running in this tab (browser actions, local file reads and - * imports), so none outlives the session that started it. + * imports), and every one a turn of this session starts later, so none outlives the session + * that started it. */ export function stopAllDesktopTools(reason: string): void { + session.abort(reason) + session = new AbortController() for (const turn of runningTurns.values()) turn.stop.abort(reason) runningTurns.clear() } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 627272e31ae..35195380b27 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -93,7 +93,8 @@ import { initTerminalTransport } from '@/lib/terminal/transport' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { chatUrl } from '@/app/workspace/[workspaceId]/home/hooks/chat-url' import { - leaseDesktopTool, + type DesktopToolTurn, + desktopToolTurn, stopDesktopTools, } from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes' import { useFilePreviewController } from '@/app/workspace/[workspaceId]/home/hooks/preview' @@ -518,10 +519,10 @@ function startClientBrowserTool( toolArgs: Record, scopeId: string, eventTs?: string, - turnStreamId?: string + desktopTurn?: DesktopToolTurn ): void { if (!isCurrentBrowserToolName(toolName)) return - const lease = turnStreamId ? leaseDesktopTool(turnStreamId) : undefined + const lease = desktopTurn?.lease() void executeBrowserToolOnClient( toolCallId, toolName, @@ -1654,7 +1655,7 @@ export function useChat( toolCallId: string, toolName: string, toolArgs: Record, - turnStreamId: string | undefined + desktopTurn: DesktopToolTurn | undefined ) => { if ( !isNativeFileTool(toolName) && @@ -1666,7 +1667,7 @@ export function useChat( return } handledClientLocalFilesystemToolIds.add(toolCallId) - const lease = turnStreamId ? leaseDesktopTool(turnStreamId) : undefined + const lease = desktopTurn?.lease() const options = { workspaceId, chatId: chatIdRef.current ?? selectedChatIdRef.current, @@ -2297,7 +2298,7 @@ export function useChat( shouldContinue?: () => boolean } ) => { - const turnStreamId = streamIdRef.current + const desktopTurn = streamIdRef.current ? desktopToolTurn(streamIdRef.current) : undefined const activityTracker = getResourceActivityTracker( expectedGen ?? streamGenRef.current, options?.targetChatId @@ -2318,7 +2319,7 @@ export function useChat( eventTs?: string ) => { const scopeId = activityScopeId() - startClientBrowserTool(toolCallId, toolName, toolArgs, scopeId, eventTs, turnStreamId) + startClientBrowserTool(toolCallId, toolName, toolArgs, scopeId, eventTs, desktopTurn) } const startClientTerminalToolForStream = ( toolCallId: string, @@ -2351,7 +2352,7 @@ export function useChat( removeResource, startClientWorkflowTool, startClientLocalFilesystemTool: (toolCallId, toolName, toolArgs) => - startClientLocalFilesystemTool(toolCallId, toolName, toolArgs, turnStreamId), + startClientLocalFilesystemTool(toolCallId, toolName, toolArgs, desktopTurn), startClientBrowserTool: startClientBrowserToolForStream, startClientTerminalTool: startClientTerminalToolForStream, startBrowserAgentRun: startBrowserAgentRunForStream, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx new file mode 100644 index 00000000000..7807983f8aa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx @@ -0,0 +1,131 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ChangeEventHandler, type ReactNode } from 'react' +import { emcnMock } from '@sim/testing/mocks/emcn.mock' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerate } = vi.hoisted(() => ({ mockGenerate: vi.fn() })) + +vi.mock('@sim/emcn', () => ({ + ...emcnMock, + Badge: ({ children }: { children?: ReactNode }) => {children}, + Button: ({ children, ...props }: { children?: ReactNode }) => ( + + ), + ChipCopyInput: ({ value }: { value?: string }) => ( + + ), + ChipInput: ({ + value, + onChange, + placeholder, + }: { + value?: string + onChange?: ChangeEventHandler + placeholder?: string + }) => , + ChipModalTabs: () =>
, + ChipSelect: () =>
, + Label: ({ children }: { children?: ReactNode }) => {children}, + Skeleton: () =>
, +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ children }: { children?: ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ + SettingsEmptyState: () => null, +})) + +vi.mock('@/hooks/queries/mothership-admin', () => ({ + useGenerateLicense: () => ({ mutate: mockGenerate, isPending: false, error: null }), + useMothershipLicenses: () => ({ data: undefined, isLoading: false }), + useMothershipRequests: () => ({ data: undefined, isLoading: false }), + useMothershipUserBreakdown: () => ({ data: undefined, isLoading: false }), +})) + +import { Mothership } from '@/app/workspace/[workspaceId]/settings/components/mothership/mothership' +import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + useSettingsDirtyStore.getState().reset() + mockGenerate.mockImplementation( + (_input: unknown, options: { onSuccess: (result: { license_key: string }) => void }) => + options.onSuccess({ license_key: 'sim_license_once' }) + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => + root.render( + + + + ) + ) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function type(placeholder: string, value: string) { + const input = container.querySelector(`input[placeholder="${placeholder}"]`) + expect(input).not.toBeNull() + act(() => { + Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set?.call( + input, + value + ) + input?.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +function generateKey() { + type('e.g. Acme Corp', 'Acme') + type('Signed order form or written approval', 'order-1') + const generate = Array.from(container.querySelectorAll('button')).find( + (button) => !button.disabled + ) + act(() => generate?.click()) +} + +function licenseKey() { + return container.querySelector('[data-testid="license-key"]')?.value +} + +describe('Mothership license generation', () => { + it('asks before leaving while the shown-once license key is on screen', () => { + generateKey() + const leave = vi.fn() + + const left = useSettingsDirtyStore.getState().requestLeave(leave) + + expect(licenseKey()).toBe('sim_license_once') + expect(left).toBe(false) + expect(leave).not.toHaveBeenCalled() + }) + + it('drops the license key when the admin confirms leaving', () => { + generateKey() + const leave = vi.fn() + useSettingsDirtyStore.getState().requestLeave(leave) + + act(() => useSettingsDirtyStore.getState().confirmLeave()) + + expect(leave).toHaveBeenCalledOnce() + expect(licenseKey()).toBeUndefined() + expect(useSettingsDirtyStore.getState().isDirty).toBe(false) + }) +}) diff --git a/apps/sim/stores/index.test.ts b/apps/sim/stores/index.test.ts index bf4be01dc6e..8bc103f0c21 100644 --- a/apps/sim/stores/index.test.ts +++ b/apps/sim/stores/index.test.ts @@ -13,7 +13,7 @@ vi.mock('@/stores/reset-all-stores', () => { return { resetAllStores: mockResetAllStores } }) -import { leaseDesktopTool } from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes' +import { desktopToolTurn } from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes' import { clearUserData, RECENT_IMPERSONATIONS_STORAGE_KEY } from '@/stores' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' @@ -113,8 +113,8 @@ describe('clearUserData', () => { }) it('cancels desktop tools still running for the signed-out identity', async () => { - const localRead = leaseDesktopTool('turn-before-sign-out') - const browserAction = leaseDesktopTool('other-turn-before-sign-out') + const localRead = desktopToolTurn('turn-before-sign-out').lease() + const browserAction = desktopToolTurn('other-turn-before-sign-out').lease() mockResetAllStores.mockImplementationOnce(() => { throw new Error('Chunk unavailable') }) From eb7723bdf36303460941010a4fa1135fa5f870dd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 13:05:40 -0700 Subject: [PATCH 2/4] fix(mothership): bind desktop tools to the session the chat surface mounted in A send or reconnect still in flight at sign-out reaches the stream reader after the stop, so a per-reader capture took the new session. The surface now takes its session once at mount; signing out leaves or reloads every chat surface. --- .../home/hooks/desktop-tool-lifetimes.test.ts | 41 +++++++++++-------- .../home/hooks/desktop-tool-lifetimes.ts | 21 +++++++--- .../[workspaceId]/home/hooks/use-chat.ts | 7 +++- apps/sim/stores/index.test.ts | 6 +-- 4 files changed, 46 insertions(+), 29 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts index b768a0be114..917314c788e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest' import { - desktopToolTurn, + desktopToolSession, stopAllDesktopTools, stopDesktopTools, } from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes' describe('desktop tool leases', () => { it('cancels every running tool of the stopped turn and no other turn', () => { - const first = desktopToolTurn('turn-a').lease() - const second = desktopToolTurn('turn-a').lease() - const other = desktopToolTurn('turn-b').lease() + const first = desktopToolSession().turn('turn-a').lease() + const second = desktopToolSession().turn('turn-a').lease() + const other = desktopToolSession().turn('turn-b').lease() stopDesktopTools('turn-a', 'user_stop') @@ -21,9 +21,10 @@ describe('desktop tool leases', () => { }) it('keeps a turn reachable by Stop while any of its tools still runs', () => { - const settled = desktopToolTurn('turn-c').lease() - const running = desktopToolTurn('turn-c').lease() - for (let turn = 0; turn < 500; turn++) desktopToolTurn(`busy-${turn}`).lease().release() + const settled = desktopToolSession().turn('turn-c').lease() + const running = desktopToolSession().turn('turn-c').lease() + for (let turn = 0; turn < 500; turn++) + desktopToolSession().turn(`busy-${turn}`).lease().release() settled.release() settled.release() @@ -33,11 +34,11 @@ describe('desktop tool leases', () => { }) it('gives a turn whose tools all settled a fresh lifetime for its next tool', () => { - const settled = desktopToolTurn('turn-d').lease() + const settled = desktopToolSession().turn('turn-d').lease() settled.release() stopDesktopTools('turn-d', 'user_stop') - const next = desktopToolTurn('turn-d').lease() + const next = desktopToolSession().turn('turn-d').lease() expect(settled.signal.aborted).toBe(false) expect(next.signal).not.toBe(settled.signal) @@ -46,9 +47,9 @@ describe('desktop tool leases', () => { }) it('does not let a tool that settles after Stop release a newer lease on the turn', () => { - const stopped = desktopToolTurn('turn-e').lease() + const stopped = desktopToolSession().turn('turn-e').lease() stopDesktopTools('turn-e', 'user_stop') - const next = desktopToolTurn('turn-e').lease() + const next = desktopToolSession().turn('turn-e').lease() stopped.release() stopDesktopTools('turn-e', 'user_stop') @@ -57,8 +58,8 @@ describe('desktop tool leases', () => { }) it('cancels the running tools of every turn when the session ends', () => { - const first = desktopToolTurn('turn-f').lease() - const second = desktopToolTurn('turn-g').lease() + const first = desktopToolSession().turn('turn-f').lease() + const second = desktopToolSession().turn('turn-g').lease() stopAllDesktopTools('signed_out') @@ -69,21 +70,25 @@ describe('desktop tool leases', () => { second.release() }) - it('cancels a tool a turn of the ended session starts after sign-out', () => { - const turn = desktopToolTurn('turn-h') + it('cancels tools of a surface mounted before sign-out, even on a stream it reads later', () => { + const surface = desktopToolSession() + const running = surface.turn('turn-h') stopAllDesktopTools('signed_out') - const late = turn.lease() + const late = running.lease() + const reconnected = surface.turn('turn-j').lease() expect(late.signal.aborted).toBe(true) expect(late.signal.reason).toBe('signed_out') + expect(reconnected.signal.aborted).toBe(true) late.release() + reconnected.release() }) - it('runs the tools of a turn started after the session ended', () => { + it('runs the tools of a surface mounted after the session ended', () => { stopAllDesktopTools('signed_out') - const next = desktopToolTurn('turn-i').lease() + const next = desktopToolSession().turn('turn-i').lease() expect(next.signal.aborted).toBe(false) next.release() diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts index 75c7e48fd3a..43e8cde05bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes.ts @@ -26,21 +26,30 @@ interface DesktopToolLease { release(): void } -/** A turn's stream, bound to the session it started in. */ +/** A turn's desktop tools, in the session of the chat surface that runs the turn. */ export interface DesktopToolTurn { /** Starts one desktop tool for the turn. */ lease(): DesktopToolLease } +/** The desktop tools a chat surface starts, bound to the session the surface mounted in. */ +export interface DesktopToolSession { + turn(streamId: string): DesktopToolTurn +} + /** - * Binds a turn's stream to the current session. Take it once, when the stream starts: its tool - * events can still arrive after a sign-out, and each of them then gets an already-aborted lease. + * Binds a chat surface to the current session. Take it once, when the surface mounts: a send or + * reconnect still in flight at sign-out can deliver tool events after the stop, and each of them + * then gets an already-aborted lease. Signing out leaves or reloads every chat surface, so a + * surface mounted after sign-in binds to the new session. */ -export function desktopToolTurn(streamId: string): DesktopToolTurn { +export function desktopToolSession(): DesktopToolSession { const startedIn = session.signal return { - lease: () => - startedIn.aborted ? { signal: startedIn, release() {} } : leaseDesktopTool(streamId), + turn: (streamId) => ({ + lease: () => + startedIn.aborted ? { signal: startedIn, release() {} } : leaseDesktopTool(streamId), + }), } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 35195380b27..8114ddee989 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -93,8 +93,9 @@ import { initTerminalTransport } from '@/lib/terminal/transport' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { chatUrl } from '@/app/workspace/[workspaceId]/home/hooks/chat-url' import { + type DesktopToolSession, type DesktopToolTurn, - desktopToolTurn, + desktopToolSession, stopDesktopTools, } from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes' import { useFilePreviewController } from '@/app/workspace/[workspaceId]/home/hooks/preview' @@ -1004,6 +1005,8 @@ export function useChat( const chatIdRef = useRef(initialChatId) /** Cleared on unmount, so late async work cannot act on a surface the user left. */ const surfaceMountedRef = useRef(true) + const desktopToolsRef = useRef(null) + const desktopTools = (desktopToolsRef.current ??= desktopToolSession()) useEffect(() => { surfaceMountedRef.current = true return () => { @@ -2298,7 +2301,7 @@ export function useChat( shouldContinue?: () => boolean } ) => { - const desktopTurn = streamIdRef.current ? desktopToolTurn(streamIdRef.current) : undefined + const desktopTurn = streamIdRef.current ? desktopTools.turn(streamIdRef.current) : undefined const activityTracker = getResourceActivityTracker( expectedGen ?? streamGenRef.current, options?.targetChatId diff --git a/apps/sim/stores/index.test.ts b/apps/sim/stores/index.test.ts index 8bc103f0c21..b7f95fd28ae 100644 --- a/apps/sim/stores/index.test.ts +++ b/apps/sim/stores/index.test.ts @@ -13,7 +13,7 @@ vi.mock('@/stores/reset-all-stores', () => { return { resetAllStores: mockResetAllStores } }) -import { desktopToolTurn } from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes' +import { desktopToolSession } from '@/app/workspace/[workspaceId]/home/hooks/desktop-tool-lifetimes' import { clearUserData, RECENT_IMPERSONATIONS_STORAGE_KEY } from '@/stores' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' @@ -113,8 +113,8 @@ describe('clearUserData', () => { }) it('cancels desktop tools still running for the signed-out identity', async () => { - const localRead = desktopToolTurn('turn-before-sign-out').lease() - const browserAction = desktopToolTurn('other-turn-before-sign-out').lease() + const localRead = desktopToolSession().turn('turn-before-sign-out').lease() + const browserAction = desktopToolSession().turn('other-turn-before-sign-out').lease() mockResetAllStores.mockImplementationOnce(() => { throw new Error('Chunk unavailable') }) From 30296e41b6874feb40c8d91b11721748ddc57523 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 13:32:16 -0700 Subject: [PATCH 3/4] test(mothership): assert the license key guard through tab and environment navigation --- .../components/mothership/mothership.test.tsx | 75 +++++++++++++++---- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx index 7807983f8aa..ec64aac1c8c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx @@ -29,8 +29,46 @@ vi.mock('@sim/emcn', () => ({ onChange?: ChangeEventHandler placeholder?: string }) => , - ChipModalTabs: () =>
, - ChipSelect: () =>
, + ChipModalTabs: ({ + tabs, + onChange, + }: { + tabs: Array<{ label: string; value: string }> + onChange: (value: string) => void + }) => ( +
+ {tabs.map((tab) => ( + + ))} +
+ ), + ChipSelect: ({ + options, + onChange, + }: { + options: Array<{ label: string; value: string }> + onChange: (value: string) => void + }) => ( +
+ {options.map((option) => ( + + ))} +
+ ), Label: ({ children }: { children?: ReactNode }) => {children}, Skeleton: () =>
, })) @@ -55,6 +93,7 @@ import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' let container: HTMLDivElement let root: Root +let urlSearch: string beforeEach(() => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -66,9 +105,15 @@ beforeEach(() => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) + urlSearch = '?tab=licenses' act(() => root.render( - + { + urlSearch = event.queryString + }} + > ) @@ -96,7 +141,7 @@ function generateKey() { type('e.g. Acme Corp', 'Acme') type('Signed order form or written approval', 'order-1') const generate = Array.from(container.querySelectorAll('button')).find( - (button) => !button.disabled + (button) => !button.dataset.tab && !button.dataset.env && !button.disabled ) act(() => generate?.click()) } @@ -105,26 +150,28 @@ function licenseKey() { return container.querySelector('[data-testid="license-key"]')?.value } +function switchToByok() { + act(() => container.querySelector('[data-tab="byok"]')?.click()) +} + describe('Mothership license generation', () => { - it('asks before leaving while the shown-once license key is on screen', () => { + it('stays on the tab while the shown-once license key is on screen', () => { generateKey() - const leave = vi.fn() - const left = useSettingsDirtyStore.getState().requestLeave(leave) + switchToByok() expect(licenseKey()).toBe('sim_license_once') - expect(left).toBe(false) - expect(leave).not.toHaveBeenCalled() + expect(urlSearch).not.toContain('byok') + expect(useSettingsDirtyStore.getState().pendingLeave).not.toBeNull() }) - it('drops the license key when the admin confirms leaving', () => { + it('drops the license key when the admin confirms switching environments', async () => { generateKey() - const leave = vi.fn() - useSettingsDirtyStore.getState().requestLeave(leave) + act(() => container.querySelector('[data-env="prod"]')?.click()) - act(() => useSettingsDirtyStore.getState().confirmLeave()) + await act(async () => useSettingsDirtyStore.getState().confirmLeave()) - expect(leave).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(urlSearch).toContain('env=prod')) expect(licenseKey()).toBeUndefined() expect(useSettingsDirtyStore.getState().isDirty).toBe(false) }) From 5ac72c90786b834c01d2df8945f0037c4775c4d8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 7 Oct 2026 14:52:35 -0700 Subject: [PATCH 4/4] test(mothership): mock Chip for the license-key guard tests on top of #8761 #8761 already guards the shown-once license key (generatedKey in isDirty, cleared on discard), so this branch keeps staging's settings page and its tests now assert that guard. The page now renders Chip, which the shared emcn mock does not provide. --- .../settings/components/mothership/mothership.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx index ec64aac1c8c..d027a0cd1eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.test.tsx @@ -12,6 +12,7 @@ const { mockGenerate } = vi.hoisted(() => ({ mockGenerate: vi.fn() })) vi.mock('@sim/emcn', () => ({ ...emcnMock, Badge: ({ children }: { children?: ReactNode }) => {children}, + Chip: ({ children }: { children?: ReactNode }) => {children}, Button: ({ children, ...props }: { children?: ReactNode }) => (