Skip to content

Commit 0c63239

Browse files
committed
fix(desktop): learn a turn's executor from its stream and announce background chats
Builds on the background activity commit, against the final binding contract. - Display only, deterministically. Sim marks a turn its desktop's executor runs on the stream that serves it: a response header on the send and on every reconnect, and a flag on the replay batch a reload attaches with. The chat view only shows that turn's desktop calls and never starts or reports them. This replaces reading the cached activity list, which a new turn's first call could beat. - Needs input comes from the durable record: a call Sim asked the user about and they have not answered, or a running terminal handoff or browser takeover. An auto-allowed command no longer reads as needing input. Presence Sim cannot read is not reported as offline. - Turn-complete notifications for chats in the background: a chat that finishes while the user is elsewhere is announced and opens on click; the chat on screen announces itself as before. "Notify only for chats in the background" (the existing default) now means exactly that, so a background chat is announced even while Sim is focused. - The activity list refreshes when a turn starts or ends.
1 parent 41f6234 commit 0c63239

21 files changed

Lines changed: 582 additions & 85 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
vi.mock('electron', () => import('@/test/electron-mock'))
4+
5+
import type { BrowserWindow } from 'electron'
6+
import { createDesktopSettingsService } from '@/main/desktop-settings'
7+
import { Notification } from '@/test/electron-mock'
8+
9+
function service(window: { url: string; focused: boolean } | null) {
10+
const config = new Map<string, unknown>()
11+
return createDesktopSettingsService({
12+
config: {
13+
get: (key: string) => config.get(key),
14+
set: (key: string, value: unknown) => config.set(key, value),
15+
flush: () => true,
16+
} as never,
17+
getMainWindow: () =>
18+
window
19+
? ({
20+
isFocused: () => window.focused,
21+
webContents: { getURL: () => window.url },
22+
} as unknown as BrowserWindow)
23+
: null,
24+
openMainWindowAt: vi.fn(),
25+
setAutoDownloadUpdates: vi.fn(),
26+
setTrayEnabled: vi.fn(),
27+
setBrowserEnabled: vi.fn(),
28+
setTerminalEnabled: vi.fn(),
29+
setBrowserTheme: vi.fn(),
30+
setBrowserDefaultZoom: vi.fn(),
31+
setTerminalDefaultZoom: vi.fn(),
32+
getDefaultBrowserDownloadDirectory: () => '/tmp',
33+
chooseBrowserDownloadDirectory: async () => null,
34+
})
35+
}
36+
37+
const BACKGROUND_CHAT = '/workspace/ws-1/chat/chat-b'
38+
39+
describe('desktop notifications in the default "background only" mode', () => {
40+
beforeEach(() => {
41+
Notification.instances.length = 0
42+
})
43+
44+
it('announces a chat in the background while the user works in another one', () => {
45+
const shown = service({
46+
url: 'https://sim.ai/workspace/ws-1/chat/chat-c',
47+
focused: true,
48+
}).notify({
49+
title: 'Fix CI',
50+
body: 'Sim finished responding.',
51+
route: BACKGROUND_CHAT,
52+
})
53+
54+
expect(shown).toBe(true)
55+
expect(Notification.instances).toHaveLength(1)
56+
})
57+
58+
it('stays quiet about the chat the focused window shows', () => {
59+
const shown = service({ url: `https://sim.ai${BACKGROUND_CHAT}`, focused: true }).notify({
60+
title: 'Fix CI',
61+
body: 'Sim finished responding.',
62+
route: BACKGROUND_CHAT,
63+
})
64+
65+
expect(shown).toBe(false)
66+
expect(Notification.instances).toHaveLength(0)
67+
})
68+
69+
it('stays quiet about the app as a whole while it is focused', () => {
70+
const shown = service({ url: 'https://sim.ai/workspace/ws-1/home', focused: true }).notify({
71+
title: 'Update ready',
72+
body: 'Restart to update.',
73+
})
74+
75+
expect(shown).toBe(false)
76+
})
77+
})

‎apps/desktop/src/main/desktop-settings.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,21 @@ function readPreferences(
105105
}
106106
}
107107

108+
/**
109+
* Whether a focused window already shows what a notification is about. One without a route is
110+
* about the app as a whole, which a focused window always shows; one about a chat is news only
111+
* while that chat is in the background.
112+
*/
113+
function showsRoute(window: BrowserWindow, route: string | undefined): boolean {
114+
if (!route) return true
115+
try {
116+
const shown = new URL(window.webContents.getURL()).pathname
117+
return shown === new URL(route, 'https://sim.invalid').pathname
118+
} catch {
119+
return false
120+
}
121+
}
122+
108123
/**
109124
* Owns device preferences and their native side effects. Renderer code can
110125
* request a change, but only this main-process service touches login items,
@@ -203,7 +218,11 @@ export function createDesktopSettingsService(
203218
return false
204219
}
205220
const window = deps.getMainWindow()
206-
if (preferences.notificationsOnlyWhenUnfocused && window?.isFocused()) {
221+
if (
222+
preferences.notificationsOnlyWhenUnfocused &&
223+
window?.isFocused() &&
224+
showsRoute(window, payload.route)
225+
) {
207226
return false
208227
}
209228

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* A chat view learns from the stream it reads whether the desktop app's background executor runs
3+
* the turn's desktop tools, so it only shows those calls. Exercised against real PostgreSQL and
4+
* Redis through the production reconnect route, for the live tail and the replay batch a view
5+
* attaches with after a reload.
6+
*/
7+
import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock'
8+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
9+
10+
const { redisUrl } = await vi.hoisted(async () => {
11+
const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure')
12+
const url = readTestRedisUrl()
13+
if (url) process.env.REDIS_URL = url
14+
return { redisUrl: url }
15+
})
16+
17+
vi.mock('@/lib/auth', () => authMock)
18+
19+
import { db } from '@sim/db'
20+
import {
21+
copilotChats,
22+
copilotRuns,
23+
desktopDevices,
24+
permissions,
25+
user,
26+
workspace,
27+
} from '@sim/db/schema'
28+
import { generateId } from '@sim/utils/id'
29+
import { eq } from 'drizzle-orm'
30+
import { NextRequest } from 'next/server'
31+
import { closeRedisConnection } from '@/lib/core/config/redis'
32+
import { MOTHERSHIP_DESKTOP_EXECUTOR_HEADER } from '@/lib/mothership/constants'
33+
import { GET as streamGET } from '@/app/api/copilot/chat/stream/route'
34+
35+
const userId = generateId()
36+
const workspaceId = generateId()
37+
const chatId = generateId()
38+
const deviceId = generateId()
39+
40+
async function finishedRun(desktopDeviceId: string | null): Promise<string> {
41+
const streamId = generateId()
42+
await db.insert(copilotRuns).values({
43+
id: generateId(),
44+
executionId: generateId(),
45+
chatId,
46+
userId,
47+
workspaceId,
48+
streamId,
49+
status: 'complete',
50+
desktopDeviceId,
51+
})
52+
return streamId
53+
}
54+
55+
function reconnect(streamId: string, batch: boolean) {
56+
return streamGET(
57+
new NextRequest(
58+
`http://localhost:3000/api/copilot/chat/stream?streamId=${streamId}&after=0${batch ? '&batch=true' : ''}`
59+
),
60+
{ params: Promise.resolve({}) }
61+
)
62+
}
63+
64+
describe.runIf(Boolean(redisUrl))('desktop executor binding on a chat stream', () => {
65+
beforeAll(async () => {
66+
const now = new Date()
67+
await db.insert(user).values({
68+
id: userId,
69+
name: 'Desktop stream fixture',
70+
email: `${userId}@desktop-stream.test`,
71+
emailVerified: true,
72+
createdAt: now,
73+
updatedAt: now,
74+
})
75+
await db.insert(workspace).values({
76+
id: workspaceId,
77+
name: 'Desktop stream fixture',
78+
ownerId: userId,
79+
billedAccountUserId: userId,
80+
})
81+
await db.insert(permissions).values({
82+
id: generateId(),
83+
userId,
84+
entityType: 'workspace',
85+
entityId: workspaceId,
86+
permissionType: 'admin',
87+
})
88+
await db.insert(copilotChats).values({ id: chatId, userId, workspaceId, type: 'mothership' })
89+
await db.insert(desktopDevices).values({
90+
id: deviceId,
91+
userId,
92+
name: 'Studio Mac',
93+
appVersion: '0.9.0',
94+
platform: 'darwin-arm64',
95+
capabilities: { executor: 1, browser: true, terminal: true, localFiles: true },
96+
})
97+
authMockFns.mockGetSession.mockResolvedValue({
98+
user: { id: userId },
99+
session: { id: generateId() },
100+
})
101+
})
102+
103+
afterAll(async () => {
104+
await db.delete(copilotRuns).where(eq(copilotRuns.chatId, chatId))
105+
await db.delete(copilotChats).where(eq(copilotChats.id, chatId))
106+
await db.delete(desktopDevices).where(eq(desktopDevices.id, deviceId))
107+
await db.delete(permissions).where(eq(permissions.userId, userId))
108+
await db.delete(workspace).where(eq(workspace.id, workspaceId))
109+
await db.delete(user).where(eq(user.id, userId))
110+
await closeRedisConnection()
111+
})
112+
113+
it('marks the live tail and the replay batch of a turn a desktop runs', async () => {
114+
const streamId = await finishedRun(deviceId)
115+
116+
const tail = await reconnect(streamId, false)
117+
await tail.text()
118+
const batch = await (await reconnect(streamId, true)).json()
119+
120+
expect(tail.headers.get(MOTHERSHIP_DESKTOP_EXECUTOR_HEADER)).toBe('device')
121+
expect(batch.desktopToolsOnDevice).toBe(true)
122+
})
123+
124+
it('leaves a turn the chat view runs unmarked', async () => {
125+
const streamId = await finishedRun(null)
126+
127+
const tail = await reconnect(streamId, false)
128+
await tail.text()
129+
const batch = await (await reconnect(streamId, true)).json()
130+
131+
expect(tail.headers.get(MOTHERSHIP_DESKTOP_EXECUTOR_HEADER)).toBeNull()
132+
expect(batch.desktopToolsOnDevice).toBeUndefined()
133+
})
134+
})

‎apps/sim/app/api/copilot/chat/stream/route.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import {
1313
} from '@/lib/api/server/routes'
1414
import { encodeSSEComment } from '@/lib/core/utils/sse'
1515
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
16-
import { MOTHERSHIP_STREAM_REPLAY_HEADER } from '@/lib/mothership/constants'
16+
import {
17+
MOTHERSHIP_DESKTOP_EXECUTOR_HEADER,
18+
MOTHERSHIP_STREAM_REPLAY_HEADER,
19+
} from '@/lib/mothership/constants'
1720
import {
1821
MothershipStreamV1CompletionStatus,
1922
MothershipStreamV1EventType,
@@ -285,6 +288,7 @@ async function handleResumeRequestBody({
285288
previewSessions,
286289
status: run.status,
287290
...(run.chatId ? { chatId: run.chatId } : {}),
291+
...(run.desktopDeviceId ? { desktopToolsOnDevice: true } : {}),
288292
})
289293
}
290294

@@ -606,8 +610,10 @@ async function handleResumeRequestBody({
606610
}
607611

608612
return new Response(stream, {
609-
headers: replayBody
610-
? { ...SSE_RESPONSE_HEADERS, [MOTHERSHIP_STREAM_REPLAY_HEADER]: 'log' }
611-
: SSE_RESPONSE_HEADERS,
613+
headers: {
614+
...SSE_RESPONSE_HEADERS,
615+
...(replayBody ? { [MOTHERSHIP_STREAM_REPLAY_HEADER]: 'log' } : {}),
616+
...(run.desktopDeviceId ? { [MOTHERSHIP_DESKTOP_EXECUTOR_HEADER]: 'device' } : {}),
617+
},
612618
})
613619
}

‎apps/sim/app/api/desktop/activity/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
desktopExecutorErrorPolicy,
55
desktopExecutorRateLimit,
66
} from '@/lib/api/server/routes/desktop-executor'
7-
import { listDesktopActivity } from '@/lib/desktop/application/executor'
7+
import { listDesktopActivity } from '@/lib/desktop/application/activity'
88

99
export const dynamic = 'force-dynamic'
1010

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export type StreamBatchResponse = {
2525
previewSessions?: FilePreviewSession[]
2626
status: string
2727
chatId?: string
28+
/** The desktop app's background executor runs this turn's desktop tools. */
29+
desktopToolsOnDevice?: true
2830
}
2931

3032
const STREAM_SCHEMA_ENFORCEMENT_PREFIX = 'Client stream schema enforcement failed.'
@@ -108,6 +110,7 @@ export function parseStreamBatchResponse(value: unknown): StreamBatchResponse {
108110
...(previewSessions ? { previewSessions } : {}),
109111
status: typeof value.status === 'string' ? value.status : 'unknown',
110112
...(typeof value.chatId === 'string' && value.chatId ? { chatId: value.chatId } : {}),
113+
...(value.desktopToolsOnDevice === true ? { desktopToolsOnDevice: true as const } : {}),
111114
}
112115
}
113116

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts‎

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -108,31 +108,47 @@ describe('tool events (dispatch → model + side effects)', () => {
108108
)
109109
})
110110

111-
it("only shows a desktop call when the chat's turn runs on a desktop in the background", () => {
111+
it("only shows desktop calls when the desktop's background executor runs the turn", () => {
112112
const startClientBrowserTool = vi.fn()
113-
const deps = makeStreamLoopDeps({ startClientBrowserTool, chatIdRef: ref('chat-1') })
114-
vi.mocked(deps.queryClient.getQueryData).mockImplementation((key) =>
115-
JSON.stringify(key) === JSON.stringify(['desktop-activity', 'list', 'ws-1'])
116-
? [{ chatId: 'chat-1', state: 'running', deviceName: 'MacBook' }]
117-
: undefined
118-
)
119-
const ctx = createStreamLoopContext(deps)
120-
121-
dispatchStreamEvent(
122-
ctx,
123-
toolEnv({
124-
phase: 'call',
125-
executor: 'client',
126-
mode: 'async',
127-
toolCallId: 'click-1',
128-
toolName: 'browser_click',
129-
arguments: { ref: 'e1' },
130-
status: 'executing',
113+
const startClientTerminalTool = vi.fn()
114+
const startClientLocalFilesystemTool = vi.fn()
115+
const startClientWorkflowTool = vi.fn()
116+
const ctx = createStreamLoopContext(
117+
makeStreamLoopDeps({
118+
startClientBrowserTool,
119+
startClientTerminalTool,
120+
startClientLocalFilesystemTool,
121+
startClientWorkflowTool,
122+
chatIdRef: ref('chat-1'),
123+
options: { desktopToolsOnDevice: true },
131124
})
132125
)
126+
const call = (toolCallId: string, toolName: string, args: Record<string, unknown>) =>
127+
dispatchStreamEvent(
128+
ctx,
129+
toolEnv({
130+
phase: 'call',
131+
executor: 'client',
132+
mode: 'async',
133+
toolCallId,
134+
toolName,
135+
arguments: args,
136+
status: 'executing',
137+
})
138+
)
139+
140+
call('click-1', 'browser_click', { ref: 'e1' })
141+
call('run-1', 'terminal', { operation: 'run', args: { command: 'ls' } })
142+
call('read-1', 'read_local_file', { path: '~/notes.txt' })
143+
call('workflow-1', 'run_workflow', { workflowId: 'wf-1' })
133144

134145
expect(toolNode(ctx, 'click-1').status).toBe('running')
135146
expect(startClientBrowserTool).not.toHaveBeenCalled()
147+
expect(startClientTerminalTool).not.toHaveBeenCalled()
148+
expect(startClientLocalFilesystemTool).not.toHaveBeenCalled()
149+
expect(startClientWorkflowTool).toHaveBeenCalledWith('workflow-1', 'run_workflow', {
150+
workflowId: 'wf-1',
151+
})
136152
})
137153

138154
it('runs a desktop call in the view when no desktop runs the chat in the background', () => {

0 commit comments

Comments
 (0)