Skip to content

Commit 2adc1bc

Browse files
committed
Restore desktop parity and preserve interrupted chat progress
1 parent 3ca9a4b commit 2adc1bc

27 files changed

Lines changed: 886 additions & 55 deletions

File tree

‎apps/desktop/e2e/browser-tools.spec.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const FORM = `<!doctype html><html><head><title>Form fixture</title></head><body
3030
test.describe('browser tools', () => {
3131
const calls = new Map<
3232
string,
33-
{ chatId: string; toolName: BrowserToolName; args: Record<string, unknown> }
33+
{ chatId: string; toolName: BrowserToolName | 'terminal'; args: Record<string, unknown> }
3434
>()
3535
let server: Server
3636
let origin: string
@@ -50,6 +50,7 @@ test.describe('browser tools', () => {
5050
let body = ''
5151
for await (const chunk of request) body += chunk.toString()
5252
const authorization = calls.get(JSON.parse(body).toolCallId)
53+
calls.delete(JSON.parse(body).toolCallId)
5354
response.writeHead(authorization ? 200 : 403, { 'Content-Type': 'application/json' })
5455
response.end(JSON.stringify(authorization ?? {}))
5556
return
@@ -269,4 +270,37 @@ test.describe('browser tools', () => {
269270
expect(fill.result).toMatchObject({ completed: false, completedCount: 0 })
270271
expect(await formState()).toMatchObject({ name: '', password: '' })
271272
})
273+
test('local terminal executes through its native PTY and refuses repeated authorization', async () => {
274+
await window.evaluate(async (scope) => {
275+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
276+
await api.terminal.activateScope(scope)
277+
await api.terminal.start({ cols: 100, rows: 30 }, scope)
278+
}, SCOPE)
279+
calls.set('local-cwd', {
280+
chatId: SCOPE,
281+
toolName: 'terminal',
282+
args: { operation: 'cwd', args: {} },
283+
})
284+
const cwd = await window.evaluate(async (scope) => {
285+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
286+
return api.terminal.executeTool('local-cwd', 'cwd', {}, scope)
287+
}, SCOPE)
288+
expect(cwd.ok).toBe(true)
289+
calls.set('local-run', {
290+
chatId: SCOPE,
291+
toolName: 'terminal',
292+
args: { operation: 'run', args: { command: "printf 'SIM_NATIVE_TERMINAL_VERIFIED\\n'" } },
293+
})
294+
const result = await window.evaluate(async (scope) => {
295+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
296+
return api.terminal.executeTool('local-run', 'run', {}, scope)
297+
}, SCOPE)
298+
expect(result.ok).toBe(true)
299+
expect(JSON.stringify(result)).toContain('SIM_NATIVE_TERMINAL_VERIFIED')
300+
const replay = await window.evaluate(async (scope) => {
301+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
302+
return api.terminal.executeTool('local-run', 'run', {}, scope)
303+
}, SCOPE)
304+
expect(replay.ok).toBe(false)
305+
})
272306
})

‎apps/desktop/src/main/browser-agent/session.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2358,6 +2358,26 @@ describe('browser-agent session', () => {
23582358
expect(session.listTabs()).toHaveLength(13)
23592359
})
23602360

2361+
it('gives background automation a viewport without taking panel ownership', () => {
2362+
const tab = session.withBrowserScope('background-chat', () => session.ensureTab())
2363+
2364+
expect(tab.view.setBounds).toHaveBeenCalledWith({
2365+
x: 0,
2366+
y: 0,
2367+
width: 1180,
2368+
height: 850,
2369+
})
2370+
expect(win.contentView.addChildView).not.toHaveBeenCalledWith(tab.view)
2371+
expect(session.getActiveBrowserScopeId()).toBe('chat-test')
2372+
})
2373+
2374+
it('initializes a detached viewport when no application window exists', () => {
2375+
const headlessSession = freshSession(null)
2376+
const tab = headlessSession.ensureTab()
2377+
2378+
expect(tab.view.setBounds).toHaveBeenCalledWith({ x: 0, y: 0, width: 1280, height: 720 })
2379+
})
2380+
23612381
it('embeds the active view in the MAIN window only while panel bounds are reported', () => {
23622382
const tab = session.ensureTab()
23632383
const view = tab.view as unknown as MockView

‎apps/desktop/src/main/browser-agent/session.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1953,6 +1953,9 @@ function createTabView(): WebContentsView {
19531953
},
19541954
})
19551955
try {
1956+
/** Detached tabs must lay out before a foreground panel owns their native view. */
1957+
const [width, height] = getMainWindow()?.getContentSize() ?? [1280, 720]
1958+
view.setBounds({ x: 0, y: 0, width: Math.max(1, width), height: Math.max(1, height) })
19561959
return initializeTabView(view, scopeId)
19571960
} catch (error) {
19581961
if (!view.webContents.isDestroyed()) view.webContents.close()

‎apps/sim/app/api/copilot/chat/stop/route.test.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,22 @@ import { authMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing'
55
import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockAppendCopilotChatMessages, mockPublishStatusChanged, mockGetAccessibleChat } =
9-
vi.hoisted(() => ({
10-
mockGetAccessibleChat: vi.fn(),
11-
mockAppendCopilotChatMessages: vi.fn(),
12-
mockPublishStatusChanged: vi.fn(),
13-
}))
8+
const {
9+
mockAppendCopilotChatMessages,
10+
mockPublishStatusChanged,
11+
mockGetAccessibleChat,
12+
mockReadEvents,
13+
} = vi.hoisted(() => ({
14+
mockGetAccessibleChat: vi.fn(),
15+
mockAppendCopilotChatMessages: vi.fn(),
16+
mockPublishStatusChanged: vi.fn(),
17+
mockReadEvents: vi.fn(),
18+
}))
1419

1520
vi.mock('@/lib/mothership/chat/lifecycle', () => ({
1621
getAccessibleCopilotChatAuth: mockGetAccessibleChat,
1722
}))
23+
vi.mock('@/lib/mothership/request/session/buffer', () => ({ readEvents: mockReadEvents }))
1824

1925
vi.mock('@/lib/mothership/chat/messages-store', () => ({
2026
appendCopilotChatMessages: mockAppendCopilotChatMessages,
@@ -60,6 +66,7 @@ describe('copilot chat stop route', () => {
6066
session: { id: 'session-1' },
6167
})
6268
mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' })
69+
mockReadEvents.mockResolvedValue([])
6370
})
6471

6572
it('does not persist stopped content after organization access is removed', async () => {
@@ -151,6 +158,7 @@ describe('copilot chat stop route', () => {
151158
createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: '' })
152159
)
153160

161+
expect(mockReadEvents).toHaveBeenCalledWith('stream-1', '0')
154162
expect(response.status).toBe(200)
155163
expect(await response.json()).toEqual({ success: true })
156164

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export const POST = withRouteHandler((req: NextRequest) =>
7777
userMessageId: streamId,
7878
assistantMessage,
7979
streamMarkerPolicy: 'active-or-cleared',
80+
preferServerReplay: true,
8081
})
8182
span.setAttribute(TraceAttr.CopilotStopAppendedAssistant, result.appendedAssistant)
8283
const stopOutcome = !result.found

‎apps/sim/app/api/mothership/execute/route.test.ts‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,135 @@ describe('mothership private trace provenance transport', () => {
270270
expect(contextRegistry).toBe(lifecycleOptions.environmentContext?.resolvedSecretTraceRegistry)
271271
})
272272

273+
it('forwards closed model controls and an ordered deduplicated safe timeline', async () => {
274+
mockRunHeadlessCopilotLifecycle.mockImplementation(
275+
async (payload: Record<string, unknown>, options: CopilotLifecycleOptions) => {
276+
expect(payload).toMatchObject({
277+
modelSelection: { model: 'gpt-6-astra', fastMode: true },
278+
effort: 'max',
279+
})
280+
const base = { v: 1 as const, ts: new Date().toISOString(), stream: { id: 'message-1' } }
281+
const thinking = {
282+
...base,
283+
seq: 1,
284+
type: 'text' as const,
285+
payload: { channel: 'thinking' as const, text: 'Considering' },
286+
}
287+
await options.onEvent?.({
288+
...base,
289+
seq: 0,
290+
type: 'text',
291+
payload: { channel: 'assistant', text: 'I will check.' },
292+
})
293+
await options.onEvent?.(thinking)
294+
await options.onEvent?.(thinking)
295+
await options.onEvent?.({
296+
...base,
297+
seq: 2,
298+
type: 'tool',
299+
payload: {
300+
phase: 'call',
301+
toolCallId: 'tool-1',
302+
toolName: 'Lookup',
303+
executor: 'go',
304+
mode: 'sync',
305+
partial: true,
306+
arguments: { secret: 'private-args' },
307+
},
308+
})
309+
await options.onEvent?.({
310+
...base,
311+
seq: 3,
312+
type: 'tool',
313+
payload: {
314+
phase: 'call',
315+
toolCallId: 'tool-1',
316+
toolName: 'Lookup',
317+
executor: 'go',
318+
mode: 'sync',
319+
},
320+
})
321+
await options.onEvent?.({
322+
...base,
323+
seq: 4,
324+
type: 'tool',
325+
payload: {
326+
phase: 'result',
327+
toolCallId: 'tool-1',
328+
toolName: 'Lookup',
329+
executor: 'go',
330+
mode: 'sync',
331+
success: true,
332+
output: 'private-result',
333+
},
334+
})
335+
await options.onEvent?.({
336+
...base,
337+
seq: 5,
338+
type: 'text',
339+
payload: { channel: 'assistant', text: 'Answer' },
340+
})
341+
return { ...successResult(), content: 'Answer' }
342+
}
343+
)
344+
const response = await POST(
345+
createMockRequest(
346+
'POST',
347+
{
348+
...requestBody,
349+
modelSelection: { model: 'gpt-6-astra', fastMode: true },
350+
effort: 'max',
351+
},
352+
{
353+
Authorization: 'Bearer internal',
354+
'x-sim-billing-attribution': 'billing',
355+
Accept: 'application/x-ndjson',
356+
},
357+
'http://localhost:3000/api/mothership/execute'
358+
),
359+
undefined
360+
)
361+
const text = await response.text()
362+
const events = text
363+
.trim()
364+
.split('\n')
365+
.map((line) => JSON.parse(line))
366+
expect(events.filter((event) => event.type === 'agent_event')).toEqual([
367+
{ type: 'agent_event', event: { type: 'thinking_delta', text: 'Considering' } },
368+
{ type: 'agent_event', event: { type: 'turn_end', turn: 'intermediate' } },
369+
{ type: 'agent_event', event: { type: 'tool_call_start', id: 'tool-1', name: 'Lookup' } },
370+
{
371+
type: 'agent_event',
372+
event: { type: 'tool_call_end', id: 'tool-1', name: 'Lookup', status: 'success' },
373+
},
374+
{ type: 'agent_event', event: { type: 'turn_end', turn: 'final' } },
375+
])
376+
expect(events.filter((event) => event.type === 'chunk')).toEqual([
377+
{ type: 'chunk', content: 'I will check.', turn: 'pending' },
378+
{ type: 'chunk', content: 'Answer', turn: 'pending' },
379+
])
380+
expect(text).not.toContain('private-args')
381+
expect(text).not.toContain('private-result')
382+
})
383+
384+
it.each([
385+
{ modelSelection: { model: 'arbitrary' } },
386+
{ modelSelection: { model: 'claude-opus-5', fastMode: true } },
387+
{ effort: 'ultra' },
388+
])('rejects unsupported controls before lifecycle dispatch: %j', async (selection) => {
389+
const response = await POST(
390+
createMockRequest(
391+
'POST',
392+
{ ...requestBody, ...selection },
393+
{ Authorization: 'Bearer internal' },
394+
'http://localhost:3000/api/mothership/execute'
395+
),
396+
undefined
397+
)
398+
expect(response.status).toBe(400)
399+
expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled()
400+
})
401+
273402
it('keeps context routing and display inputs raw until the lifecycle boundary', async () => {
274403
mockGetPersonalAndWorkspaceEnv.mockResolvedValueOnce({
275404
personalEncrypted: { API_KEY: 'encrypted-secret' },

‎apps/sim/app/api/mothership/execute/route.ts‎

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
148148
const {
149149
messages,
150150
responseFormat,
151+
modelSelection,
152+
effort,
151153
workspaceId,
152154
userId: bodyUserId,
153155
chatId,
@@ -301,6 +303,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
301303
)
302304
const requestPayload: Record<string, unknown> = {
303305
messages: wireMessages,
306+
...(modelSelection ? { modelSelection } : {}),
307+
...(effort ? { effort } : {}),
304308
...(responseFormat !== undefined ? { responseFormat } : {}),
305309
userId,
306310
protocolVersion: PROTOCOL_VERSION,
@@ -377,7 +381,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
377381

378382
const stream = new ReadableStream<Uint8Array>({
379383
start(controller) {
380-
let lastForwardedTextSeq = -1
384+
let lastForwardedSeq = -1
385+
const startedTools = new Set<string>()
386+
const endedTools = new Set<string>()
381387
const send = (event: unknown) => {
382388
if (!cancelled) {
383389
controller.enqueue(encodeNdjson(event))
@@ -394,20 +400,53 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
394400
void (async () => {
395401
try {
396402
const result = await runLifecycle(async (event) => {
397-
if (
398-
event.type === MothershipStreamV1EventType.text &&
399-
event.payload.channel === MothershipStreamV1TextChannel.assistant &&
400-
event.payload.text
401-
) {
402-
/* The wire carries text DELTAS with monotone seqs; a transport-retry
403-
replay re-delivers earlier seqs. Dedupe replays by seq — the old
404-
string-prefix guess sliced characters off a genuine delta that
405-
happened to begin with the already-forwarded content. */
406-
if (typeof event.seq === 'number') {
407-
if (event.seq <= lastForwardedTextSeq) return
408-
lastForwardedTextSeq = event.seq
403+
/** Reconnect replays share the same monotone sequence across event types. */
404+
if (typeof event.seq === 'number') {
405+
if (event.seq <= lastForwardedSeq) return
406+
lastForwardedSeq = event.seq
407+
}
408+
if (event.type === MothershipStreamV1EventType.text && event.payload.text) {
409+
if (event.payload.channel === MothershipStreamV1TextChannel.assistant) {
410+
if (event.scope?.lane === 'subagent') return
411+
send({ type: 'chunk', content: event.payload.text, turn: 'pending' })
412+
} else if (event.payload.channel === MothershipStreamV1TextChannel.thinking) {
413+
send({
414+
type: 'agent_event',
415+
event: { type: 'thinking_delta', text: event.payload.text },
416+
})
417+
}
418+
} else if (event.type === MothershipStreamV1EventType.tool) {
419+
const tool = event.payload
420+
if (!('phase' in tool)) return
421+
if (tool.phase === 'call' && !startedTools.has(tool.toolCallId)) {
422+
startedTools.add(tool.toolCallId)
423+
if (event.scope?.lane !== 'subagent' && !tool.replay) {
424+
send({
425+
type: 'agent_event',
426+
event: { type: 'turn_end', turn: 'intermediate' },
427+
})
428+
}
429+
send({
430+
type: 'agent_event',
431+
event: { type: 'tool_call_start', id: tool.toolCallId, name: tool.toolName },
432+
})
433+
} else if (tool.phase === 'result' && !endedTools.has(tool.toolCallId)) {
434+
endedTools.add(tool.toolCallId)
435+
send({
436+
type: 'agent_event',
437+
event: {
438+
type: 'tool_call_end',
439+
id: tool.toolCallId,
440+
name: tool.toolName,
441+
status:
442+
tool.status === 'cancelled'
443+
? 'cancelled'
444+
: tool.success
445+
? 'success'
446+
: 'error',
447+
},
448+
})
409449
}
410-
send({ type: 'chunk', content: event.payload.text })
411450
}
412451
})
413452
allowExplicitAbort = false
@@ -450,6 +489,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
450489
return
451490
}
452491

492+
send({ type: 'agent_event', event: { type: 'turn_end', turn: 'final' } })
453493
send({
454494
type: 'final',
455495
data: withPrivateProvenance(

0 commit comments

Comments
 (0)