Skip to content
91 changes: 91 additions & 0 deletions apps/desktop/src/main/desktop-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('electron', () => import('@/test/electron-mock'))

import type { BrowserWindow } from 'electron'
import { createDesktopSettingsService } from '@/main/desktop-settings'
import { Notification } from '@/test/electron-mock'

function service(window: { url: string; focused: boolean } | null) {
const config = new Map<string, unknown>()
return createDesktopSettingsService({
config: {
get: (key: string) => config.get(key),
set: (key: string, value: unknown) => config.set(key, value),
flush: () => true,
} as never,
getMainWindow: () =>
window
? ({
isFocused: () => window.focused,
webContents: { getURL: () => window.url },
} as unknown as BrowserWindow)
: null,
openMainWindowAt: vi.fn(),
setAutoDownloadUpdates: vi.fn(),
setTrayEnabled: vi.fn(),
setBrowserEnabled: vi.fn(),
setTerminalEnabled: vi.fn(),
setBrowserTheme: vi.fn(),
setBrowserDefaultZoom: vi.fn(),
setTerminalDefaultZoom: vi.fn(),
getDefaultBrowserDownloadDirectory: () => '/tmp',
chooseBrowserDownloadDirectory: async () => null,
})
}

const BACKGROUND_CHAT = '/workspace/ws-1/chat/chat-b'

describe('desktop notifications in the default "background only" mode', () => {
beforeEach(() => {
Notification.instances.length = 0
})

it('announces a chat in the background while the user works in another one', () => {
const shown = service({
url: 'https://sim.ai/workspace/ws-1/chat/chat-c',
focused: true,
}).notify({
title: 'Fix CI',
body: 'Sim finished responding.',
route: BACKGROUND_CHAT,
background: true,
})

expect(shown).toBe(true)
expect(Notification.instances).toHaveLength(1)
})

it('stays quiet about the chat the focused window shows', () => {
const shown = service({ url: `https://sim.ai${BACKGROUND_CHAT}`, focused: true }).notify({
title: 'Fix CI',
body: 'Sim finished responding.',
route: BACKGROUND_CHAT,
background: true,
})

expect(shown).toBe(false)
expect(Notification.instances).toHaveLength(0)
})

it('stays quiet about the app as a whole while it is focused', () => {
const shown = service({ url: 'https://sim.ai/workspace/ws-1/home', focused: true }).notify({
title: 'Update ready',
body: 'Restart to update.',
})

expect(shown).toBe(false)
})

it('holds back an ordinary notification whenever the window is focused, wherever it is', () => {
// The workflow panel's chat completes while the user watches it on the workflow page.
const shown = service({ url: 'https://sim.ai/workspace/ws-1/w/wf-1', focused: true }).notify({
title: 'Task complete',
body: 'Sim finished responding.',
route: BACKGROUND_CHAT,
})

expect(shown).toBe(false)
expect(Notification.instances).toHaveLength(0)
})
})
23 changes: 22 additions & 1 deletion apps/desktop/src/main/desktop-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ function readPreferences(
}
}

/**
* Whether a focused window already shows what a notification is about. One without a route is
* about the app as a whole, which a focused window always shows; one about a chat is news only
* while that chat is in the background.
*/
function showsRoute(window: BrowserWindow, route: string | undefined): boolean {
if (!route) return true
try {
const shown = new URL(window.webContents.getURL()).pathname
return shown === new URL(route, 'https://sim.invalid').pathname
} catch {
return false
}
}

/**
* Owns device preferences and their native side effects. Renderer code can
* request a change, but only this main-process service touches login items,
Expand Down Expand Up @@ -203,7 +218,13 @@ export function createDesktopSettingsService(
return false
}
const window = deps.getMainWindow()
if (preferences.notificationsOnlyWhenUnfocused && window?.isFocused()) {
// A focused window holds back every notification, as it always has. Only a background
// chat's completion is shown anyway when the focused window is somewhere else.
if (
preferences.notificationsOnlyWhenUnfocused &&
window?.isFocused() &&
(!payload.background || showsRoute(window, payload.route))
) {
return false
}

Expand Down
10 changes: 8 additions & 2 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,10 +305,11 @@ export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificati
if (typeof raw !== 'object' || raw === null) {
return null
}
const { title, body, route } = raw as {
const { title, body, route, background } = raw as {
title?: unknown
body?: unknown
route?: unknown
background?: unknown
}
if (
typeof title !== 'string' ||
Expand All @@ -323,7 +324,12 @@ export function parseDesktopNotificationPayload(raw: unknown): DesktopNotificati
if (route !== undefined && (typeof route !== 'string' || !isSafeInternalPath(route))) {
return null
}
return { title, body, ...(route !== undefined ? { route } : {}) }
return {
title,
body,
...(route !== undefined ? { route } : {}),
...(background === true ? { background: true } : {}),
}
}

export interface IpcDeps {
Expand Down
134 changes: 134 additions & 0 deletions apps/sim/app/api/copilot/chat/stream/desktop-executor.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* A chat view learns from the stream it reads whether the desktop app's background executor runs
* the turn's desktop tools, so it only shows those calls. Exercised against real PostgreSQL and
* Redis through the production reconnect route, for the live tail and the replay batch a view
* attaches with after a reload.
*/
import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'

const { redisUrl } = await vi.hoisted(async () => {
const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure')
const url = readTestRedisUrl()
if (url) process.env.REDIS_URL = url
return { redisUrl: url }
})

vi.mock('@/lib/auth', () => authMock)

import { db } from '@sim/db'
import {
copilotChats,
copilotRuns,
desktopDevices,
permissions,
user,
workspace,
} from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { NextRequest } from 'next/server'
import { closeRedisConnection } from '@/lib/core/config/redis'
import { MOTHERSHIP_DESKTOP_EXECUTOR_HEADER } from '@/lib/mothership/constants'
import { GET as streamGET } from '@/app/api/copilot/chat/stream/route'

const userId = generateId()
const workspaceId = generateId()
const chatId = generateId()
const deviceId = generateId()

async function finishedRun(desktopDeviceId: string | null): Promise<string> {
const streamId = generateId()
await db.insert(copilotRuns).values({
id: generateId(),
executionId: generateId(),
chatId,
userId,
workspaceId,
streamId,
status: 'complete',
desktopDeviceId,
})
return streamId
}

function reconnect(streamId: string, batch: boolean) {
return streamGET(
new NextRequest(
`http://localhost:3000/api/copilot/chat/stream?streamId=${streamId}&after=0${batch ? '&batch=true' : ''}`
),
{ params: Promise.resolve({}) }
)
}

describe.runIf(Boolean(redisUrl))('desktop executor binding on a chat stream', () => {
beforeAll(async () => {
const now = new Date()
await db.insert(user).values({
id: userId,
name: 'Desktop stream fixture',
email: `${userId}@desktop-stream.test`,
emailVerified: true,
createdAt: now,
updatedAt: now,
})
await db.insert(workspace).values({
id: workspaceId,
name: 'Desktop stream fixture',
ownerId: userId,
billedAccountUserId: userId,
})
await db.insert(permissions).values({
id: generateId(),
userId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: 'admin',
})
await db.insert(copilotChats).values({ id: chatId, userId, workspaceId, type: 'mothership' })
await db.insert(desktopDevices).values({
id: deviceId,
userId,
name: 'Studio Mac',
appVersion: '0.9.0',
platform: 'darwin-arm64',
capabilities: { executor: 1, browser: true, terminal: true, localFiles: true },
})
authMockFns.mockGetSession.mockResolvedValue({
user: { id: userId },
session: { id: generateId() },
})
})

afterAll(async () => {
await db.delete(copilotRuns).where(eq(copilotRuns.chatId, chatId))
await db.delete(copilotChats).where(eq(copilotChats.id, chatId))
await db.delete(desktopDevices).where(eq(desktopDevices.id, deviceId))
await db.delete(permissions).where(eq(permissions.userId, userId))
await db.delete(workspace).where(eq(workspace.id, workspaceId))
await db.delete(user).where(eq(user.id, userId))
await closeRedisConnection()
})

it('marks the live tail and the replay batch of a turn a desktop runs', async () => {
const streamId = await finishedRun(deviceId)

const tail = await reconnect(streamId, false)
await tail.text()
const batch = await (await reconnect(streamId, true)).json()

expect(tail.headers.get(MOTHERSHIP_DESKTOP_EXECUTOR_HEADER)).toBe('device')
expect(batch.desktopToolsOnDevice).toBe(true)
})

it('leaves a turn the chat view runs unmarked', async () => {
const streamId = await finishedRun(null)

const tail = await reconnect(streamId, false)
await tail.text()
const batch = await (await reconnect(streamId, true)).json()

expect(tail.headers.get(MOTHERSHIP_DESKTOP_EXECUTOR_HEADER)).toBeNull()
expect(batch.desktopToolsOnDevice).toBeUndefined()
})
})
14 changes: 10 additions & 4 deletions apps/sim/app/api/copilot/chat/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
} from '@/lib/api/server/routes'
import { encodeSSEComment } from '@/lib/core/utils/sse'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { MOTHERSHIP_STREAM_REPLAY_HEADER } from '@/lib/mothership/constants'
import {
MOTHERSHIP_DESKTOP_EXECUTOR_HEADER,
MOTHERSHIP_STREAM_REPLAY_HEADER,
} from '@/lib/mothership/constants'
import {
MothershipStreamV1CompletionStatus,
MothershipStreamV1EventType,
Expand Down Expand Up @@ -285,6 +288,7 @@ async function handleResumeRequestBody({
previewSessions,
status: run.status,
...(run.chatId ? { chatId: run.chatId } : {}),
...(run.desktopDeviceId ? { desktopToolsOnDevice: true } : {}),
})
}

Expand Down Expand Up @@ -606,8 +610,10 @@ async function handleResumeRequestBody({
}

return new Response(stream, {
headers: replayBody
? { ...SSE_RESPONSE_HEADERS, [MOTHERSHIP_STREAM_REPLAY_HEADER]: 'log' }
: SSE_RESPONSE_HEADERS,
headers: {
...SSE_RESPONSE_HEADERS,
...(replayBody ? { [MOTHERSHIP_STREAM_REPLAY_HEADER]: 'log' } : {}),
...(run.desktopDeviceId ? { [MOTHERSHIP_DESKTOP_EXECUTOR_HEADER]: 'device' } : {}),
},
})
}
20 changes: 20 additions & 0 deletions apps/sim/app/api/desktop/activity/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { listDesktopActivityContract } from '@/lib/api/contracts/desktop-executor'
import { defineInternalJsonRoute, internalSessionAuth } from '@/lib/api/server/routes'
import {
desktopActivityRateLimit,
desktopExecutorErrorPolicy,
} from '@/lib/api/server/routes/desktop-executor'
import { listDesktopActivity } from '@/lib/desktop/application/activity'

export const dynamic = 'force-dynamic'

export const GET = defineInternalJsonRoute({
contract: listDesktopActivityContract,
auth: internalSessionAuth,
operation: listDesktopActivity.operation,
rateLimit: desktopActivityRateLimit,
errorPolicy: desktopExecutorErrorPolicy,
mapInput: ({ query }) => ({ workspaceId: query.workspaceId }),
useCase: listDesktopActivity,
staticResponseHeaders: { 'Cache-Control': 'no-store' },
})
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ async function render(
'table-row-ttl': false,
'mothership-model-selector': mocks.advanced,
'mothership-plan-mode': mocks.plan,
'mothership-desktop-background-executor': false,
}}
>
<Harness />
Expand Down Expand Up @@ -313,6 +314,7 @@ it.each([
'table-row-ttl': false,
'mothership-model-selector': false,
'mothership-plan-mode': planEnabled,
'mothership-desktop-background-executor': false,
}}
>
<Harness />
Expand Down Expand Up @@ -408,6 +410,7 @@ it('keeps restored queued skills scoped when replacing a draft', async () => {
'table-row-ttl': false,
'mothership-model-selector': mocks.advanced,
'mothership-plan-mode': mocks.plan,
'mothership-desktop-background-executor': false,
}}
>
<Harness />
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/o/[organizationId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export default async function OrganizationLayout({
'table-row-ttl': tableRowTtlEnabled,
'mothership-model-selector': modelSelectorEnabled,
'mothership-plan-mode': planModeEnabled,
'mothership-desktop-background-executor': false,
Comment thread
waleedlatif1 marked this conversation as resolved.
}}
>
<OrganizationProvider context={context}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type StreamBatchResponse = {
previewSessions?: FilePreviewSession[]
status: string
chatId?: string
/** The desktop app's background executor runs this turn's desktop tools. */
desktopToolsOnDevice?: true
}

const STREAM_SCHEMA_ENFORCEMENT_PREFIX = 'Client stream schema enforcement failed.'
Expand Down Expand Up @@ -108,6 +110,7 @@ export function parseStreamBatchResponse(value: unknown): StreamBatchResponse {
...(previewSessions ? { previewSessions } : {}),
status: typeof value.status === 'string' ? value.status : 'unknown',
...(typeof value.chatId === 'string' && value.chatId ? { chatId: value.chatId } : {}),
...(value.desktopToolsOnDevice === true ? { desktopToolsOnDevice: true as const } : {}),
}
}

Expand Down
Loading
Loading