Skip to content

Commit 111abd2

Browse files
committed
fix(mothership): preserve stopped chat admission and queued corrections
1 parent a4f1bd1 commit 111abd2

21 files changed

Lines changed: 699 additions & 154 deletions

File tree

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

Lines changed: 77 additions & 4 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',
@@ -271,7 +272,81 @@ describe('POST /api/copilot/chat/abort', () => {
271272
'chat-1'
272273
)
273274
expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith(
274-
expect.objectContaining({ chatId: 'chat-1', userId: 'user-1', workspaceId: 'workspace-1' })
275+
expect.objectContaining({ chatId: 'chat-1', userId: 'user-1', streamId: 'stream-1' })
276+
)
277+
})
278+
279+
it('preserves organization scope through parsing for a chatless pre-admission Stop', async () => {
280+
mockGetLatestRunForStream.mockResolvedValue(null)
281+
mockRequestRunStop.mockResolvedValue(null)
282+
const response = await POST(
283+
createMockRequest('POST', { streamId: 'early-stream', organizationId: 'org-1' })
284+
)
285+
expect(response.status).toBe(200)
286+
expect(await response.json()).toEqual({ aborted: true, settled: true })
287+
expect(mockOrganizationAuthorize).toHaveBeenCalledWith(
288+
expect.objectContaining({ kind: 'session', userId: 'user-1' }),
289+
expect.objectContaining({ id: 'mothership.runs.abort', minimumRole: 'member' }),
290+
{ organizationId: 'org-1' }
291+
)
292+
expect(mockRequestRunStop).toHaveBeenCalledWith({
293+
streamId: 'early-stream',
294+
userId: 'user-1',
295+
organizationId: 'org-1',
296+
workspaceId: undefined,
297+
})
298+
expect(mockWorkspaceContext).not.toHaveBeenCalled()
299+
expect(mockAuthorize).not.toHaveBeenCalled()
300+
expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled()
301+
})
302+
303+
it('refuses mixed owner scopes in the HTTP contract before protected lookup', async () => {
304+
const response = await POST(
305+
createMockRequest('POST', {
306+
streamId: 'early-stream',
307+
organizationId: 'org-1',
308+
workspaceId: 'workspace-1',
309+
})
310+
)
311+
expect(response.status).toBe(400)
312+
expect(mockGetLatestRunForStream).not.toHaveBeenCalled()
313+
expect(mockRequestRunStop).not.toHaveBeenCalled()
314+
})
315+
316+
it('rechecks organization membership before writing a pre-admission Stop', async () => {
317+
mockGetLatestRunForStream.mockResolvedValue(null)
318+
mockOrganizationAuthorize.mockRejectedValueOnce(
319+
new OrchestrationError('forbidden', 'Membership revoked')
320+
)
321+
const response = await POST(
322+
createMockRequest('POST', { streamId: 'early-stream', organizationId: 'org-1' })
323+
)
324+
expect(response.status).toBe(403)
325+
expect(mockRequestRunStop).not.toHaveBeenCalled()
326+
})
327+
328+
it('binds an organization admission that wins the Stop race before signalling it', async () => {
329+
mockGetLatestRunForStream.mockResolvedValue(null)
330+
mockChatContext.mockResolvedValue({
331+
chatId: 'new-chat',
332+
userId: 'user-1',
333+
organizationId: 'org-1',
334+
})
335+
mockRequestRunStop.mockResolvedValue({
336+
chatId: 'new-chat',
337+
workspaceId: null,
338+
organizationId: 'org-1',
339+
})
340+
const response = await POST(
341+
createMockRequest('POST', { streamId: 'early-stream', organizationId: 'org-1' })
342+
)
343+
expect(response.status).toBe(200)
344+
expect(await response.json()).toMatchObject({ settled: true })
345+
expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith(
346+
expect.objectContaining({
347+
streamId: 'early-stream',
348+
chatId: 'new-chat',
349+
})
275350
)
276351
})
277352

@@ -296,8 +371,6 @@ describe('POST /api/copilot/chat/abort', () => {
296371
expect(mockAuthorize).not.toHaveBeenCalled()
297372
expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith(
298373
expect.objectContaining({
299-
organizationId: 'org-1',
300-
workspaceId: undefined,
301374
chatId: 'chat-1',
302375
userId: 'user-1',
303376
})
@@ -399,7 +472,7 @@ describe('POST /api/copilot/chat/abort', () => {
399472
)
400473
expect(response.status).toBe(200)
401474
expect(mockRequestExplicitStreamAbort).toHaveBeenCalledWith(
402-
expect.objectContaining({ chatId: 'chat-1', userId: 'user-1', workspaceId: 'workspace-1' })
475+
expect.objectContaining({ chatId: 'chat-1', userId: 'user-1' })
403476
)
404477
})
405478
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)