Skip to content

Commit 1a717ef

Browse files
committed
fix(mothership): preserve stopped chat admission and queued corrections
1 parent 587657b commit 1a717ef

21 files changed

Lines changed: 698 additions & 153 deletions

File tree

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

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ describe('POST /api/copilot/chat/abort', () => {
115115
allowPersonalApiKeys: false,
116116
})
117117
mockAuthorize.mockResolvedValue(undefined)
118+
mockOrganizationAuthorize.mockResolvedValue(undefined)
118119
mockBannedUsers.mockResolvedValue([])
119120
mockWorkspaceContext.mockResolvedValue({
120121
workspaceId: 'workspace-1',
@@ -263,6 +264,80 @@ describe('POST /api/copilot/chat/abort', () => {
263264
expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'stream-1')
264265
})
265266

267+
it('preserves organization scope through parsing for a chatless pre-admission Stop', async () => {
268+
mockGetLatestRunForStream.mockResolvedValue(null)
269+
mockRequestRunStop.mockResolvedValue(null)
270+
const response = await POST(
271+
createMockRequest('POST', { streamId: 'early-stream', organizationId: 'org-1' })
272+
)
273+
expect(response.status).toBe(200)
274+
expect(await response.json()).toEqual({ aborted: true, settled: true })
275+
expect(mockOrganizationAuthorize).toHaveBeenCalledWith(
276+
expect.objectContaining({ kind: 'session', userId: 'user-1' }),
277+
expect.objectContaining({ id: 'mothership.runs.abort', minimumRole: 'member' }),
278+
{ organizationId: 'org-1' }
279+
)
280+
expect(mockRequestRunStop).toHaveBeenCalledWith({
281+
streamId: 'early-stream',
282+
userId: 'user-1',
283+
organizationId: 'org-1',
284+
workspaceId: undefined,
285+
})
286+
expect(mockWorkspaceContext).not.toHaveBeenCalled()
287+
expect(mockAuthorize).not.toHaveBeenCalled()
288+
expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled()
289+
})
290+
291+
it('refuses mixed owner scopes in the HTTP contract before protected lookup', async () => {
292+
const response = await POST(
293+
createMockRequest('POST', {
294+
streamId: 'early-stream',
295+
organizationId: 'org-1',
296+
workspaceId: 'workspace-1',
297+
})
298+
)
299+
expect(response.status).toBe(400)
300+
expect(mockGetLatestRunForStream).not.toHaveBeenCalled()
301+
expect(mockRequestRunStop).not.toHaveBeenCalled()
302+
})
303+
304+
it('rechecks organization membership before writing a pre-admission Stop', async () => {
305+
mockGetLatestRunForStream.mockResolvedValue(null)
306+
mockOrganizationAuthorize.mockRejectedValueOnce(
307+
new OrchestrationError('forbidden', 'Membership revoked')
308+
)
309+
const response = await POST(
310+
createMockRequest('POST', { streamId: 'early-stream', organizationId: 'org-1' })
311+
)
312+
expect(response.status).toBe(403)
313+
expect(mockRequestRunStop).not.toHaveBeenCalled()
314+
})
315+
316+
it('binds an organization admission that wins the Stop race before signalling it', async () => {
317+
mockGetLatestRunForStream.mockResolvedValue(null)
318+
mockChatContext.mockResolvedValue({
319+
chatId: 'new-chat',
320+
userId: 'user-1',
321+
organizationId: 'org-1',
322+
})
323+
mockRequestRunStop.mockResolvedValue({
324+
chatId: 'new-chat',
325+
workspaceId: null,
326+
organizationId: 'org-1',
327+
})
328+
const response = await POST(
329+
createMockRequest('POST', { streamId: 'early-stream', organizationId: 'org-1' })
330+
)
331+
expect(response.status).toBe(200)
332+
expect(await response.json()).toMatchObject({ settled: true })
333+
expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith(
334+
expect.objectContaining({
335+
streamId: 'early-stream',
336+
chatId: 'new-chat',
337+
})
338+
)
339+
})
340+
266341
it('stops an owned organization stream without borrowing a workspace grant', async () => {
267342
mockChatContext.mockResolvedValue({
268343
userId: 'user-1',
@@ -284,8 +359,6 @@ describe('POST /api/copilot/chat/abort', () => {
284359
expect(mockAuthorize).not.toHaveBeenCalled()
285360
expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith(
286361
expect.objectContaining({
287-
organizationId: 'org-1',
288-
workspaceId: undefined,
289362
chatId: 'chat-1',
290363
userId: 'user-1',
291364
})
@@ -387,7 +460,7 @@ describe('POST /api/copilot/chat/abort', () => {
387460
)
388461
expect(response.status).toBe(200)
389462
expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith(
390-
expect.objectContaining({ chatId: 'chat-1', userId: 'user-1', workspaceId: 'workspace-1' })
463+
expect.objectContaining({ chatId: 'chat-1', userId: 'user-1' })
391464
)
392465
})
393466
it('rejects a run admitted in a different scope during the lookup race', async () => {

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
326326
streamId: messageId,
327327
userId,
328328
chatId: effectiveChatId,
329-
workspaceId,
330329
}).catch((error) => {
331330
reqLogger.warn('Failed to send explicit abort for mothership execution', {
332331
error: toError(error).message,

‎apps/sim/app/api/v2/chat/route.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,6 @@ export const POST = withRouteHandler(
367367
streamId: messageId,
368368
userId,
369369
chatId,
370-
workspaceId,
371370
}).catch((error) => {
372371
reqLogger.warn('Failed to send explicit abort for chat request', {
373372
error: toError(error).message,

‎apps/sim/app/workspace/[workspaceId]/home/components/queued-messages/queued-messages.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export function QueuedMessages({
165165
e.stopPropagation()
166166
void onSendNow(msg.id)
167167
}}
168+
aria-label='Send now'
168169
className='rounded-md p-[5px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-active)] hover-hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover-hover:bg-transparent disabled:hover-hover:text-[var(--text-icon)]'
169170
>
170171
<ArrowUp className='size-[13px]' />
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/** @vitest-environment node */
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { parsePersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
4+
import { handleSessionEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event'
5+
import { createStreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
6+
import { makeStreamLoopDeps } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers'
7+
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
8+
9+
describe('generated chat title sidebar updates', () => {
10+
it.each([undefined, 'organization'])(
11+
'refreshes the owning sidebar and title callback for organization %s',
12+
(organizationId) => {
13+
const onTitleUpdate = vi.fn()
14+
const deps = makeStreamLoopDeps({
15+
organizationId,
16+
onTitleUpdateRef: { current: onTitleUpdate },
17+
})
18+
const parsed = parsePersistedStreamEventEnvelope({
19+
v: 1,
20+
type: 'session',
21+
seq: 1,
22+
ts: '2026-09-15T00:00:00.000Z',
23+
stream: { streamId: 'stream', chatId: 'chat' },
24+
payload: { kind: 'title', title: 'Workflow planning' },
25+
})
26+
if (!parsed.ok || parsed.event.type !== 'session')
27+
throw new Error('Invalid title event fixture')
28+
handleSessionEvent(createStreamLoopContext(deps), parsed.event)
29+
expect(deps.queryClient.invalidateQueries).toHaveBeenCalledWith({
30+
queryKey: organizationId
31+
? mothershipChatKeys.organizationList(organizationId)
32+
: mothershipChatKeys.list(deps.workspaceId),
33+
})
34+
expect(onTitleUpdate).toHaveBeenCalledOnce()
35+
}
36+
)
37+
})

0 commit comments

Comments
 (0)