From d563a9916aaf8cdf50e5abb9f0958539316af3f6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 Oct 2026 21:53:47 -0700 Subject: [PATCH 1/3] fix(copilot): load workflow chat history through the windowed chat routes --- apps/sim/app/api/copilot/chat/queries.ts | 183 ------------------ apps/sim/app/api/mothership/chat/route.ts | 17 +- .../api/mothership/chats/[chatId]/route.ts | 1 - apps/sim/hooks/queries/mothership-chats.ts | 25 +-- apps/sim/lib/api/contracts/copilot.ts | 8 - .../sim/lib/api/contracts/mothership-chats.ts | 9 - .../mothership/chat/application/context.ts | 18 ++ .../chat/application/read-older-messages.ts | 9 +- .../read-stream-status.integration.ts | 47 ++++- .../chat/application/read-stream-status.ts | 7 +- .../sim/lib/mothership/chat/lifecycle.test.ts | 10 - apps/sim/lib/mothership/chat/lifecycle.ts | 92 ++++----- .../chat/transcript-pages.integration.ts | 76 ++++++-- .../session/replay-budget.integration.ts | 60 ++++-- .../mocks/mothership-chat-lifecycle.mock.ts | 6 +- scripts/check-explicit-any.baseline.json | 1 - .../check-source-conventions.baseline.json | 1 - scripts/check-unused-exports.baseline.json | 1 - 18 files changed, 207 insertions(+), 364 deletions(-) delete mode 100644 apps/sim/app/api/copilot/chat/queries.ts diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts deleted file mode 100644 index ff17ad6570c..00000000000 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import { toError } from '@sim/utils/errors' -import { and, desc, eq, isNull } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { buildEffectiveChatTranscript } from '@/lib/mothership/chat/effective-transcript' -import { getAccessibleCopilotChat } from '@/lib/mothership/chat/lifecycle' -import { - type LiveTurnSnapshot, - readLiveTurnSnapshot, -} from '@/lib/mothership/chat/live-turn-snapshot' -import { compactToolCallParams, normalizeMessage } from '@/lib/mothership/chat/persisted-message' -import type { MothershipEffort } from '@/lib/mothership/model-options' -import { - authenticateCopilotRequestSessionOnly, - createBadRequestResponse, - createForbiddenResponse, - createInternalServerErrorResponse, - createUnauthorizedResponse, -} from '@/lib/mothership/request/http' -import { - assertActiveWorkspaceAccess, - isWorkspaceAccessDeniedError, -} from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CopilotChatAPI') - -function transformChat(chat: { - id: string - title: string | null - model: string | null - messages: unknown - config?: unknown - effort?: MothershipEffort | null - conversationId?: string | null - resources?: unknown - createdAt: Date | null - updatedAt: Date | null -}) { - return { - id: chat.id, - title: chat.title, - model: chat.model, - messages: Array.isArray(chat.messages) ? chat.messages : [], - messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, - config: chat.config || null, - effort: chat.effort ?? null, - ...('conversationId' in chat ? { activeStreamId: chat.conversationId || null } : {}), - ...('resources' in chat - ? { resources: Array.isArray(chat.resources) ? chat.resources : [] } - : {}), - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, - } -} - -type CopilotChatListRow = Pick< - typeof copilotChats.$inferSelect, - 'id' | 'title' | 'model' | 'createdAt' | 'updatedAt' -> - -function transformChatListItem(chat: CopilotChatListRow) { - return { - id: chat.id, - title: chat.title, - model: chat.model, - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, - } -} - -export async function GET(req: NextRequest) { - try { - const { searchParams } = new URL(req.url) - const workflowId = searchParams.get('workflowId') - const workspaceId = searchParams.get('workspaceId') - const chatId = searchParams.get('chatId') - - const { userId: authenticatedUserId, isAuthenticated } = - await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !authenticatedUserId) { - return createUnauthorizedResponse() - } - - if (chatId) { - const chat = await getAccessibleCopilotChat(chatId, authenticatedUserId) - if (!chat) { - return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) - } - - let streamSnapshot: LiveTurnSnapshot | null = null - if (chat.conversationId) { - try { - streamSnapshot = await readLiveTurnSnapshot(chat.conversationId, authenticatedUserId) - } catch (error) { - logger.warn('Failed to load copilot chat stream snapshot', { - chatId, - conversationId: chat.conversationId, - error: toError(error).message, - }) - } - } - - const normalizedMessages = Array.isArray(chat.messages) - ? chat.messages - .filter((message): message is Record => Boolean(message)) - .map(normalizeMessage) - : [] - const effectiveMessages = buildEffectiveChatTranscript({ - messages: normalizedMessages, - activeStreamId: chat.conversationId || null, - ...(streamSnapshot ? { streamSnapshot } : {}), - }).map(compactToolCallParams) - - logger.info(`Retrieved chat ${chatId}`) - return NextResponse.json({ - success: true, - chat: { - ...transformChat(chat), - messages: effectiveMessages, - ...(streamSnapshot ? { streamSnapshot } : {}), - }, - }) - } - - if (!workflowId && !workspaceId) { - return createBadRequestResponse('workflowId, workspaceId, or chatId is required') - } - - if (workspaceId) { - await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId) - } - - if (workflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: authenticatedUserId, - action: 'read', - }) - if (!authorization.allowed) { - return createUnauthorizedResponse() - } - } - - const scopeFilter = workflowId - ? eq(copilotChats.workflowId, workflowId) - : eq(copilotChats.workspaceId, workspaceId!) - - const chats = await db - .select({ - id: copilotChats.id, - title: copilotChats.title, - model: copilotChats.model, - createdAt: copilotChats.createdAt, - updatedAt: copilotChats.updatedAt, - }) - .from(copilotChats) - .where( - and( - eq(copilotChats.userId, authenticatedUserId), - isNull(copilotChats.deletedAt), - scopeFilter - ) - ) - .orderBy(desc(copilotChats.updatedAt)) - - const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}` - logger.info(`Retrieved ${chats.length} chats for ${scope}`) - - return NextResponse.json({ - success: true, - chats: chats.map(transformChatListItem), - }) - } catch (error) { - if (isWorkspaceAccessDeniedError(error)) { - return createForbiddenResponse('Workspace access denied') - } - logger.error('Error fetching copilot chats:', error) - return createInternalServerErrorResponse('Failed to fetch chats') - } -} diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 62316bdd8f8..74f5c159b9f 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -1,14 +1,9 @@ import { type NextRequest, NextResponse } from 'next/server' -import { - mothershipChatGetQuerySchema, - mothershipChatPostEnvelopeSchema, -} from '@/lib/api/contracts/mothership-chats' -import { validationErrorResponse } from '@/lib/api/server' +import { mothershipChatPostEnvelopeSchema } from '@/lib/api/contracts/mothership-chats' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { handleUnifiedChatPost } from '@/lib/mothership/chat/post' import { validateShimEnvelope } from '@/lib/mothership/request/http' -import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries' /** * Caps this response on serverless hosts only; the Node server bounds nothing with it. @@ -17,16 +12,6 @@ import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries' */ export const maxDuration = 3600 -// Unified chat route surface. -export const GET = withRouteHandler((request: NextRequest) => { - const validation = mothershipChatGetQuerySchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams.entries()) - ) - if (!validation.success) return validationErrorResponse(validation.error) - - return copilotChatGet(request) -}) - export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() if (!session?.user?.id) { diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 0eb3c34de5d..eacabc1f83d 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -53,7 +53,6 @@ export const GET = withRouteHandler( const chat = await getAccessibleCopilotChatWithMessages(chatId, userId, { principal, - type: 'mothership', window: fromPosition ? { kind: 'from', position: fromPosition } : window === 'latest' diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index d18f4b2a317..a97408d6d87 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -370,29 +370,12 @@ async function readMothershipChatHistory( windowStartCursor: string | null | undefined, signal?: AbortSignal ): Promise { - try { - const data = await requestJson(getMothershipChatContract, { - params: { chatId }, - query: windowStartCursor ? { from: windowStartCursor } : { window: 'latest' }, - signal, - }) - return parseChatHistory(data) - } catch (error) { - if (!isApiClientError(error) || error.status !== 404) throw error - } - - // boundary-raw-fetch: legacy alias path /api/mothership/chat?chatId=... returns the - // copilot lifecycle shape (activeStreamId, not conversationId) for chats stored under - // the older copilot table; no contract exists for this alias path - const copilotRes = await fetch(`/api/mothership/chat?chatId=${encodeURIComponent(chatId)}`, { + const data = await requestJson(getMothershipChatContract, { + params: { chatId }, + query: windowStartCursor ? { from: windowStartCursor } : { window: 'latest' }, signal, }) - - if (!copilotRes.ok) { - throw new Error('Failed to load chat') - } - - return parseChatHistory(await copilotRes.json()) + return parseChatHistory(data) } /** diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index ec3057c5a43..72a5e3fbb27 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -186,14 +186,6 @@ export const copilotToolExecuteInternalBodySchema = z ) export type CopilotToolExecuteInternalBody = z.input -export const copilotChatGetQuerySchema = z - .object({ - workflowId: z.string().optional(), - workspaceId: z.string().optional(), - chatId: z.string().optional(), - }) - .passthrough() - export const copilotChatStreamQuerySchema = z.object({ streamId: z.string().optional().default(''), after: z.string().optional().default(''), diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 8d5de94df52..3fccdbf253d 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -168,15 +168,6 @@ export type MothershipExecuteBody = z.input export const mothershipEventsQuerySchema = mothershipChatOwnerSchema -export const mothershipChatGetQuerySchema = z - .object({ - workflowId: z.string().optional(), - workspaceId: z.string().optional(), - organizationId: z.string().optional(), - chatId: z.string().optional(), - }) - .passthrough() - export const mothershipChatPostEnvelopeSchema = z .object({ message: z.string().max(MAX_CHAT_MESSAGE_LENGTH).optional(), diff --git a/apps/sim/lib/mothership/chat/application/context.ts b/apps/sim/lib/mothership/chat/application/context.ts index c12c348830f..bcf2ec22292 100644 --- a/apps/sim/lib/mothership/chat/application/context.ts +++ b/apps/sim/lib/mothership/chat/application/context.ts @@ -6,6 +6,7 @@ import { getActivelyBannedUserIds } from '@/lib/auth/ban' import type { WorkspaceAuthorizationContext } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { conversationModeSelection } from '@/lib/mothership/chat/intent' +import { authorizeChatWorkflowRead, isChatModeAvailable } from '@/lib/mothership/chat/lifecycle' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' export type ChatOwnerContext = @@ -25,6 +26,7 @@ export async function resolveOwnedChatContext(principal: Principal, chatId: stri userId: copilotChats.userId, workspaceId: copilotChats.workspaceId, organizationId: copilotChats.organizationId, + workflowId: copilotChats.workflowId, type: copilotChats.type, mode: conversationModeSelection, memorySpaceId: copilotChats.memorySpaceId, @@ -53,8 +55,24 @@ export async function resolveOwnedChatContext(principal: Principal, chatId: stri ...owner, chatId, userId: chat.userId, + workflowId: chat.workflowId, type: chat.type, mode: chat.mode, memorySpaceId: chat.memorySpaceId ?? null, } } + +/** + * Refuses a chat its owner may no longer read, as the chat detail read does: a workflow chat + * whose workflow they cannot read, or a workspace Plan chat while Plan mode is off for them. + */ +export async function requireOwnedChatReadable( + context: Awaited> +) { + if ( + (context.workflowId && + !(await authorizeChatWorkflowRead(context.chatId, context.userId, context.workflowId))) || + !(await isChatModeAvailable(context, context.userId)) + ) + throw new OrchestrationError('not_found', 'Chat not found') +} diff --git a/apps/sim/lib/mothership/chat/application/read-older-messages.ts b/apps/sim/lib/mothership/chat/application/read-older-messages.ts index a6fed1f17f3..48fa778bef3 100644 --- a/apps/sim/lib/mothership/chat/application/read-older-messages.ts +++ b/apps/sim/lib/mothership/chat/application/read-older-messages.ts @@ -3,7 +3,10 @@ import { defineWorkspaceOperation } from '@/lib/core/application' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' -import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' +import { + requireOwnedChatReadable, + resolveOwnedChatContext, +} from '@/lib/mothership/chat/application/context' import { loadCopilotChatMessagesBefore } from '@/lib/mothership/chat/lifecycle' import { compactToolCallParams, normalizeMessage } from '@/lib/mothership/chat/persisted-message' import { decodeTranscriptCursor } from '@/lib/mothership/chat/transcript-cursor' @@ -14,7 +17,7 @@ interface ReadChatOlderMessagesInput { } /** - * A page of the owner's mothership chat transcript before a cursor the chat detail read + * A page of the owner's chat transcript before a cursor the chat detail read * issued, normalized and compacted exactly as that read returns its messages, so a message * the backfill has not reached still arrives without its bulk tool-call params. */ @@ -47,7 +50,7 @@ export const readChatOlderMessages = defineAuthorizedChatUseCase({ async execute({ context, input }) { const position = decodeTranscriptCursor(input.before) if (!position) throw new OrchestrationError('validation', 'Invalid cursor') - if (context.type !== 'mothership') throw new OrchestrationError('not_found', 'Chat not found') + await requireOwnedChatReadable(context) const page = await loadCopilotChatMessagesBefore(context.chatId, position) return { messages: page.messages.map((message) => diff --git a/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts b/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts index a87dcb62e31..3d866304dd2 100644 --- a/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts +++ b/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts @@ -1,6 +1,6 @@ /** Exercises the transcript-free stream status read against real PostgreSQL rows. */ import { db } from '@sim/db' -import { copilotChats, permissions, user, workspace } from '@sim/db/schema' +import { copilotChats, permissions, user, workflow, workspace } from '@sim/db/schema' import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { generateId } from '@sim/utils/id' @@ -14,10 +14,36 @@ const workspaceId = generateId() const owner = createSessionPrincipal({ userId: ownerId, sessionId: generateId() }) const member = createSessionPrincipal({ userId: memberId, sessionId: generateId() }) -async function createChat(type: 'mothership' | 'copilot'): Promise { +async function createChat(): Promise { const [chat] = await db .insert(copilotChats) - .values({ userId: ownerId, workspaceId, type }) + .values({ userId: ownerId, workspaceId, type: 'mothership' }) + .returning({ id: copilotChats.id }) + return chat.id +} + +/** A workflow Copilot panel chat, as the panel stores it. */ +async function createWorkflowChat(options: { archivedWorkflow?: boolean } = {}): Promise { + const workflowId = generateId() + const now = new Date() + await db.insert(workflow).values({ + id: workflowId, + userId: ownerId, + workspaceId, + name: 'Chat stream status workflow', + lastSynced: now, + createdAt: now, + updatedAt: now, + archivedAt: options.archivedWorkflow ? now : null, + }) + const [chat] = await db + .insert(copilotChats) + .values({ + userId: ownerId, + workflowId, + workspaceId, + type: 'copilot', + }) .returning({ id: copilotChats.id }) return chat.id } @@ -60,21 +86,28 @@ afterAll(async () => { describe('reading whether a chat is still streaming', () => { it('answers the owner of an idle chat with no active stream', async () => { - const chatId = await createChat('mothership') + const chatId = await createChat() await expect( readChatStreamStatus.execute({ principal: owner, input: { chatId } }) ).resolves.toEqual({ activeStreamId: null }) }) it('refuses another member of the workspace, as the chat detail read does', async () => { - const chatId = await createChat('mothership') + const chatId = await createChat() await expect( readChatStreamStatus.execute({ principal: member, input: { chatId } }) ).rejects.toThrow('Chat not found') }) - it('refuses a workflow copilot chat, which the chat detail read does not serve', async () => { - const chatId = await createChat('copilot') + it('answers the owner of a workflow copilot chat', async () => { + const chatId = await createWorkflowChat() + await expect( + readChatStreamStatus.execute({ principal: owner, input: { chatId } }) + ).resolves.toEqual({ activeStreamId: null }) + }) + + it('refuses a workflow copilot chat whose workflow is archived, as the chat detail read does', async () => { + const chatId = await createWorkflowChat({ archivedWorkflow: true }) await expect( readChatStreamStatus.execute({ principal: owner, input: { chatId } }) ).rejects.toThrow('Chat not found') diff --git a/apps/sim/lib/mothership/chat/application/read-stream-status.ts b/apps/sim/lib/mothership/chat/application/read-stream-status.ts index f4634c10eac..f5bd1e56e7e 100644 --- a/apps/sim/lib/mothership/chat/application/read-stream-status.ts +++ b/apps/sim/lib/mothership/chat/application/read-stream-status.ts @@ -6,7 +6,10 @@ import { defineWorkspaceOperation } from '@/lib/core/application' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' -import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' +import { + requireOwnedChatReadable, + resolveOwnedChatContext, +} from '@/lib/mothership/chat/application/context' import { reconcileChatStreamMarkers } from '@/lib/mothership/chat/stream-liveness' interface ReadChatStreamStatusInput { @@ -44,6 +47,7 @@ export const readChatStreamStatus = defineAuthorizedChatUseCase({ }, authorizationOptions: {}, async execute({ context }) { + await requireOwnedChatReadable(context) const [chat] = await db .select({ conversationId: copilotChats.conversationId }) .from(copilotChats) @@ -51,7 +55,6 @@ export const readChatStreamStatus = defineAuthorizedChatUseCase({ and( eq(copilotChats.id, context.chatId), eq(copilotChats.userId, context.userId), - eq(copilotChats.type, 'mothership'), isNull(copilotChats.deletedAt) ) ) diff --git a/apps/sim/lib/mothership/chat/lifecycle.test.ts b/apps/sim/lib/mothership/chat/lifecycle.test.ts index 795d18b6d50..f02fef1fe8b 100644 --- a/apps/sim/lib/mothership/chat/lifecycle.test.ts +++ b/apps/sim/lib/mothership/chat/lifecycle.test.ts @@ -28,7 +28,6 @@ vi.mock('@/lib/mothership/chat/organization-chats', () => mothershipOrganization vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) import { - getAccessibleCopilotChat, getAccessibleCopilotChatAuth, getAccessibleCopilotChatForCancellation, getAccessibleCopilotChatWithMessages, @@ -132,15 +131,6 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { expect(dbChainMockFns.orderBy).not.toHaveBeenCalled() }) - it('legacy getAccessibleCopilotChat also assembles messages from copilot_messages', async () => { - dbChainMockFns.limit.mockResolvedValueOnce([{ ...chatRow, model: 'm', config: null }]) - dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }]) - - const result = await getAccessibleCopilotChat(CHAT_ID, USER_ID) - - expect(result?.messages).toEqual([userMsg]) - }) - it('scopes the chat lookup to the requesting user, not the chat id alone', async () => { dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) dbChainMockFns.orderBy.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/mothership/chat/lifecycle.ts b/apps/sim/lib/mothership/chat/lifecycle.ts index c094584fab7..a00130cd38e 100644 --- a/apps/sim/lib/mothership/chat/lifecycle.ts +++ b/apps/sim/lib/mothership/chat/lifecycle.ts @@ -80,18 +80,6 @@ const copilotChatDetailColumns = { updatedAt: copilotChats.updatedAt, } as const -/** - * Column set for the legacy copilot chat detail endpoint. Extends - * `copilotChatDetailColumns` with `model` and `config` — the - * fields the legacy `transformChat` response shape includes. Still drops - * `previewYaml` (JSONB), `pinned`, and `lastSeenAt`. - */ -const copilotChatLegacyDetailColumns = { - ...copilotChatDetailColumns, - model: copilotChats.model, - config: copilotChats.config, -} as const - /** Canonical transcript order; `NULLS LAST` sorts a not-yet-sequenced row after sequenced ones. */ const TRANSCRIPT_ORDER = [ sql`${copilotMessages.seq} asc nulls last`, @@ -305,7 +293,7 @@ type CopilotChatAuthRow = Pick< 'id' | 'userId' | 'workflowId' | 'workspaceId' | 'organizationId' | 'type' > & { mode: ConversationMode } -export type CopilotChatDetail = Pick< +type CopilotChatDetail = Pick< typeof copilotChats.$inferSelect, | 'id' | 'userId' @@ -325,8 +313,35 @@ export type CopilotChatDetailRow = CopilotChatDetail & { messages: unknown[] } -export type CopilotChatLegacyDetailRow = CopilotChatDetailRow & - Pick +/** + * A chat's workflow when the user may read it, or null when the workflow is archived, missing, + * or unreadable. A workflow chat is authorized through its workflow, whose workspace also scopes + * a legacy chat row that carries no workspace of its own. + */ +export async function authorizeChatWorkflowRead( + chatId: string, + userId: string, + workflowId: string +) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId, + action: 'read', + }) + if (!authorization.allowed || !authorization.workflow) { + logger.warn('Copilot chat workflow not authorized for user', { chatId, userId, workflowId }) + return null + } + return authorization.workflow +} + +/** A workspace Plan chat is unavailable while Plan mode is off for its owner. */ +export async function isChatModeAvailable( + chat: { organizationId: string | null | undefined; mode: ConversationMode }, + userId: string +): Promise { + return chat.organizationId || chat.mode !== 'plan' ? true : isPlanModeEnabled(userId) +} async function authorizeCopilotChatRow( chat: T | undefined, @@ -360,19 +375,7 @@ async function authorizeCopilotChatRow( throw error } } else if (chat.workflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: chat.workflowId, - userId, - action: 'read', - }) - if (!authorization.allowed || !authorization.workflow) { - logger.warn('Copilot chat workflow not authorized for user', { - chatId, - userId, - workflowId: chat.workflowId, - }) - return null - } + if (!(await authorizeChatWorkflowRead(chatId, userId, chat.workflowId))) return null } else if (chat.workspaceId) { const access = await checkWorkspaceAccess(chat.workspaceId, userId) if (!access.exists || !access.hasAccess) { @@ -386,10 +389,8 @@ async function authorizeCopilotChatRow( } if ( - !chat.organizationId && - chat.mode === 'plan' && organizationAuthorization !== authorizeOrganizationChatCancellation && - !(await isPlanModeEnabled(userId)) + !(await isChatModeAvailable(chat, userId)) ) return null @@ -446,29 +447,6 @@ async function loadAccessibleCopilotChatAuth( return authorizeCopilotChatRow(chat, chatId, userId, principal, organizationAuthorization) } -/** - * Load a copilot chat row for the legacy chat detail endpoint, including the - * transcript plus `model` and `config`. Drops `previewYaml` - * (JSONB), `pinned`, and `lastSeenAt` — none of which the endpoint returns. - */ -export async function getAccessibleCopilotChat( - chatId: string, - userId: string, - options?: { principal?: Principal } -): Promise { - const [chat] = await db - .select(copilotChatLegacyDetailColumns) - .from(copilotChats) - .where(ownedLiveChatWhere(chatId, userId)) - .limit(1) - - const authorized = await authorizeCopilotChatRow(chat, chatId, userId, options?.principal) - if (!authorized) return null - - const messages = await loadCopilotChatMessages(chatId, { projection: 'display' }) - return { ...authorized, messages } -} - /** * Load a copilot chat's detail columns after authorization, without its * transcript. The transcript is unbounded — no per-chat message cap on write @@ -493,20 +471,18 @@ async function getAccessibleCopilotChatDetail( * Load a copilot chat with its transcript (whole, or the `window` asked for) and resources after * authorization, omitting copilot-only TOAST-able fields (`previewYaml`, `config`) and unused * metadata (`model`, `pinned`, `lastSeenAt`). Messages arrive in the chat UI's projection, so - * this serves the mothership chat detail endpoint. A chat whose type differs from `type` is null - * before its transcript is read. + * this serves the chat detail endpoint for mothership and workflow copilot chats alike. */ export async function getAccessibleCopilotChatWithMessages( chatId: string, userId: string, options?: { principal?: Principal - type?: CopilotChatDetail['type'] window?: TranscriptWindow } ): Promise<(CopilotChatDetailRow & Omit) | null> { const chat = await getAccessibleCopilotChatDetail(chatId, userId, options?.principal) - if (!chat || (options?.type && chat.type !== options.type)) return null + if (!chat) return null if (!options?.window) { const messages = await loadCopilotChatMessages(chatId, { projection: 'display' }) diff --git a/apps/sim/lib/mothership/chat/transcript-pages.integration.ts b/apps/sim/lib/mothership/chat/transcript-pages.integration.ts index 84dded49161..a24ece5795f 100644 --- a/apps/sim/lib/mothership/chat/transcript-pages.integration.ts +++ b/apps/sim/lib/mothership/chat/transcript-pages.integration.ts @@ -1,6 +1,13 @@ /** Exercises transcript windows and the stored UI projection against real PostgreSQL. */ import { db } from '@sim/db' -import { copilotChats, copilotMessages, permissions, user, workspace } from '@sim/db/schema' +import { + copilotChats, + copilotMessages, + permissions, + user, + workflow, + workspace, +} from '@sim/db/schema' import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { generateId } from '@sim/utils/id' @@ -117,6 +124,32 @@ async function createChat(type: 'mothership' | 'copilot' = 'mothership'): Promis return chat.id } +/** A workflow Copilot panel chat, as the panel stores it. */ +async function createWorkflowChat(options: { archivedWorkflow?: boolean } = {}): Promise { + const workflowId = generateId() + const now = new Date() + await db.insert(workflow).values({ + id: workflowId, + userId: ownerId, + workspaceId, + name: 'Transcript pages workflow', + lastSynced: now, + createdAt: now, + updatedAt: now, + archivedAt: options.archivedWorkflow ? now : null, + }) + const [chat] = await db + .insert(copilotChats) + .values({ + userId: ownerId, + workflowId, + workspaceId, + type: 'copilot', + }) + .returning({ id: copilotChats.id }) + return chat.id +} + async function appendTurns(chatId: string, turns: number, firstTurn = 0): Promise { const messages: PersistedMessage[] = [] for (let turn = firstTurn; turn < firstTurn + turns; turn++) { @@ -334,22 +367,7 @@ describe('chat transcript pages in PostgreSQL', () => { ) }) - it('reads a transcript only for a chat of the requested type', async () => { - const copilotChatId = await createChat('copilot') - const mothershipChatId = await createChat('mothership') - await appendTurns(copilotChatId, 1) - await appendTurns(mothershipChatId, 1) - - expect( - await getAccessibleCopilotChatWithMessages(copilotChatId, ownerId, { type: 'mothership' }) - ).toBeNull() - const mothership = await getAccessibleCopilotChatWithMessages(mothershipChatId, ownerId, { - type: 'mothership', - }) - expect(mothership?.messages).toHaveLength(2) - }) - - it("serves older pages only to the chat's owner, only for mothership chats and issued cursors", async () => { + it("serves older pages only to the chat's owner and only for issued cursors", async () => { const chatId = await createChat() await appendTurns(chatId, 40) const before = (await latestWindow(chatId))?.olderCursor @@ -364,12 +382,28 @@ describe('chat transcript pages in PostgreSQL', () => { await expect( readChatOlderMessages.execute({ principal: outsider, input: { chatId, before } }) ).rejects.toThrow('Chat not found') - const copilotChatId = await createChat('copilot') - await expect( - readChatOlderMessages.execute({ principal: owner, input: { chatId: copilotChatId, before } }) - ).rejects.toThrow('Chat not found') await expect( readChatOlderMessages.execute({ principal: owner, input: { chatId, before: 'tampered' } }) ).rejects.toThrow('Invalid cursor') }) + + it('serves older pages of a workflow copilot chat while its owner may read the workflow', async () => { + const chatId = await createWorkflowChat() + const archivedChatId = await createWorkflowChat({ archivedWorkflow: true }) + for (const id of [chatId, archivedChatId]) await appendTurns(id, 40) + + const before = (await latestWindow(chatId))?.olderCursor + if (!before) throw new Error('latest window reaches the first message') + const page = await readChatOlderMessages.execute({ + principal: owner, + input: { chatId, before }, + }) + expect(page.messages.length).toBeGreaterThan(0) + await expect( + readChatOlderMessages.execute({ principal: outsider, input: { chatId, before } }) + ).rejects.toThrow('Chat not found') + await expect( + readChatOlderMessages.execute({ principal: owner, input: { chatId: archivedChatId, before } }) + ).rejects.toThrow('Chat not found') + }) }) diff --git a/apps/sim/lib/mothership/request/session/replay-budget.integration.ts b/apps/sim/lib/mothership/request/session/replay-budget.integration.ts index 8c530c1aa5c..3d723c3176f 100644 --- a/apps/sim/lib/mothership/request/session/replay-budget.integration.ts +++ b/apps/sim/lib/mothership/request/session/replay-budget.integration.ts @@ -134,7 +134,7 @@ vi.mock('@/lib/mothership/request/lifecycle/run', () => ({ import { trace } from '@opentelemetry/api' import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' import { db } from '@sim/db' -import { copilotChats, copilotRuns, permissions, user, workspace } from '@sim/db/schema' +import { copilotChats, copilotRuns, permissions, user, workflow, workspace } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { NextRequest } from 'next/server' @@ -171,7 +171,6 @@ import { type PendingFileIntent, storeFileIntent, } from '@/lib/mothership/tools/server/files/intent-store' -import { GET as copilotChatGET } from '@/app/api/copilot/chat/queries' import { GET as streamGET } from '@/app/api/copilot/chat/stream/route' import { GET as mothershipChatGET } from '@/app/api/mothership/chats/[chatId]/route' @@ -986,30 +985,51 @@ describe.runIf(Boolean(redisUrl))('a turn whose stream exhausts its replay budge expect(worker.runs.map((run) => run.recoveredEvents)).toEqual([[]]) }) + const loadChat = () => + mothershipChatGET(new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}`), { + params: Promise.resolve({ chatId }), + }) + + /** Serves the chat as a workflow Copilot panel chat for the length of one read. */ + async function loadAsWorkflowCopilotChat() { + const workflowId = generateId() + const now = new Date() + await db.insert(workflow).values({ + id: workflowId, + userId, + workspaceId, + name: 'Copilot panel workflow', + lastSynced: now, + createdAt: now, + updatedAt: now, + }) + await db + .update(copilotChats) + .set({ type: 'copilot', workflowId }) + .where(eq(copilotChats.id, chatId)) + try { + return await loadChat() + } finally { + await db + .update(copilotChats) + .set({ type: 'mothership', workflowId: null }) + .where(eq(copilotChats.id, chatId)) + await db.delete(workflow).where(eq(workflow.id, workflowId)) + } + } + it.each([ - [ - 'mothership chat', - () => - mothershipChatGET( - new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}`), - { - params: Promise.resolve({ chatId }), - } - ), - ], - [ - 'copilot chat', - () => - copilotChatGET( - new NextRequest(`http://localhost:3000/api/copilot/chat?chatId=${chatId}`) - ), - ], + ['mothership chat', loadChat], + ['workflow copilot chat', loadAsWorkflowCopilotChat], ])('leaves the %s snapshot to the resume route', async (_label, load) => { const streamId = await trimmedRecoverableStream() - const body = await (await load()).json() + const response = await load() + expect(response.status).toBe(200) + const body = await response.json() expect(body.success).toBe(true) + expect(body.chat.mode).toBe('agent') expect(body.chat.streamSnapshot).toBeUndefined() expect(JSON.stringify(body.chat.messages)).not.toContain('part 3') expect(await storedMembers(streamId)).toHaveLength(2) diff --git a/packages/testing/src/mocks/mothership-chat-lifecycle.mock.ts b/packages/testing/src/mocks/mothership-chat-lifecycle.mock.ts index ff2a418b067..3770c2daf72 100644 --- a/packages/testing/src/mocks/mothership-chat-lifecycle.mock.ts +++ b/packages/testing/src/mocks/mothership-chat-lifecycle.mock.ts @@ -17,9 +17,10 @@ export const mothershipChatLifecycleMockFns = { mockLoadChatFileAttachmentsNamed: vi.fn(), mockGetAccessibleCopilotChatAuth: vi.fn(), mockGetAccessibleCopilotChatForCancellation: vi.fn(), - mockGetAccessibleCopilotChat: vi.fn(), mockGetAccessibleCopilotChatWithMessages: vi.fn(), mockResolveOrCreateChat: vi.fn(), + mockAuthorizeChatWorkflowRead: vi.fn(), + mockIsChatModeAvailable: vi.fn(async () => true), } /** @@ -37,8 +38,9 @@ export const mothershipChatLifecycleMock = { getAccessibleCopilotChatAuth: mothershipChatLifecycleMockFns.mockGetAccessibleCopilotChatAuth, getAccessibleCopilotChatForCancellation: mothershipChatLifecycleMockFns.mockGetAccessibleCopilotChatForCancellation, - getAccessibleCopilotChat: mothershipChatLifecycleMockFns.mockGetAccessibleCopilotChat, getAccessibleCopilotChatWithMessages: mothershipChatLifecycleMockFns.mockGetAccessibleCopilotChatWithMessages, resolveOrCreateChat: mothershipChatLifecycleMockFns.mockResolveOrCreateChat, + authorizeChatWorkflowRead: mothershipChatLifecycleMockFns.mockAuthorizeChatWorkflowRead, + isChatModeAvailable: mothershipChatLifecycleMockFns.mockIsChatModeAvailable, } diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json index 1307ccb7f40..ba500819022 100644 --- a/scripts/check-explicit-any.baseline.json +++ b/scripts/check-explicit-any.baseline.json @@ -1115,7 +1115,6 @@ "apps/sim/app/api/billing/invoices/route.ts": 2, "apps/sim/app/api/billing/route.ts": 1, "apps/sim/app/api/chat/[identifier]/route.ts": 1, - "apps/sim/app/api/copilot/chat/queries.ts": 1, "apps/sim/app/api/copilot/chat/stream/route.test.ts": 1, "apps/sim/app/api/copilot/chat/stream/route.ts": 1, "apps/sim/app/api/copilot/chats/route.ts": 2, diff --git a/scripts/check-source-conventions.baseline.json b/scripts/check-source-conventions.baseline.json index 77a252cfe29..83492b0745a 100644 --- a/scripts/check-source-conventions.baseline.json +++ b/scripts/check-source-conventions.baseline.json @@ -588,7 +588,6 @@ "apps/sim/app/api/chat/utils.ts": 2, "apps/sim/app/api/chat/validate/route.ts": 2, "apps/sim/app/api/copilot/api-keys/validate/route.ts": 2, - "apps/sim/app/api/copilot/chat/queries.ts": 2, "apps/sim/app/api/copilot/chats/route.ts": 2, "apps/sim/app/api/copilot/feedback/route.ts": 2, "apps/sim/app/api/cron/reconcile-inbox-entitlement/route.ts": 1, diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index 03034dd8599..0dcd68ae086 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -1310,7 +1310,6 @@ "apps/sim/lib/api/contracts/copilot.ts#ValidateCopilotByokBody", "apps/sim/lib/api/contracts/copilot.ts#copilotApiKeySchema", "apps/sim/lib/api/contracts/copilot.ts#copilotChatAbortBodySchema", - "apps/sim/lib/api/contracts/copilot.ts#copilotChatGetQuerySchema", "apps/sim/lib/api/contracts/copilot.ts#copilotChatListItemSchema", "apps/sim/lib/api/contracts/copilot.ts#copilotChatSteerBodySchema", "apps/sim/lib/api/contracts/copilot.ts#copilotChatStreamQuerySchema", From dc79a922b521bde0c707c913f49c2c49248f7ba5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 Oct 2026 22:16:39 -0700 Subject: [PATCH 2/3] fix(copilot): scope workflow chats without a workspace through their workflow --- .../mothership/chat/application/context.ts | 23 +++++++++++++-- .../read-stream-status.integration.ts | 24 ++++++++++----- apps/sim/lib/mothership/chat/lifecycle.ts | 2 +- .../chat/transcript-pages.integration.ts | 29 +++++++++++++------ scripts/check-explicit-any.baseline.json | 1 - 5 files changed, 58 insertions(+), 21 deletions(-) diff --git a/apps/sim/lib/mothership/chat/application/context.ts b/apps/sim/lib/mothership/chat/application/context.ts index bcf2ec22292..c3ec42214da 100644 --- a/apps/sim/lib/mothership/chat/application/context.ts +++ b/apps/sim/lib/mothership/chat/application/context.ts @@ -36,7 +36,8 @@ export async function resolveOwnedChatContext(principal: Principal, chatId: stri .limit(1) if ( !chat || - Boolean(chat.workspaceId) === Boolean(chat.organizationId) || + (chat.workspaceId && chat.organizationId) || + (!chat.workspaceId && !chat.organizationId && !chat.workflowId) || chat.userId !== resolvePrincipalSubjectUserId(principal) || (chat.organizationId && chat.type !== 'mothership') ) { @@ -48,7 +49,9 @@ export async function resolveOwnedChatContext(principal: Principal, chatId: stri const owner: ChatOwnerContext = chat.organizationId ? { organizationId: chat.organizationId } : { - ...(await resolveActiveWorkspaceApplicationContext(chat.workspaceId!)), + ...(await resolveActiveWorkspaceApplicationContext( + await resolveChatWorkspaceId(chatId, chat) + )), organizationId: undefined, } return { @@ -62,6 +65,22 @@ export async function resolveOwnedChatContext(principal: Principal, chatId: stri } } +/** + * A workflow chat row from before chats carried a workspace is scoped by its workflow's, while + * its owner may still read that workflow. + */ +async function resolveChatWorkspaceId( + chatId: string, + chat: { userId: string; workspaceId: string | null; workflowId: string | null } +): Promise { + if (chat.workspaceId) return chat.workspaceId + const workflow = chat.workflowId + ? await authorizeChatWorkflowRead(chatId, chat.userId, chat.workflowId) + : null + if (!workflow?.workspaceId) throw new OrchestrationError('not_found', 'Chat not found') + return workflow.workspaceId +} + /** * Refuses a chat its owner may no longer read, as the chat detail read does: a workflow chat * whose workflow they cannot read, or a workspace Plan chat while Plan mode is off for them. diff --git a/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts b/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts index 3d866304dd2..fd92b566eaf 100644 --- a/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts +++ b/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts @@ -22,8 +22,13 @@ async function createChat(): Promise { return chat.id } -/** A workflow Copilot panel chat, as the panel stores it. */ -async function createWorkflowChat(options: { archivedWorkflow?: boolean } = {}): Promise { +/** + * A workflow Copilot panel chat, as the panel stores it. A legacy row, from before chats carried + * a workspace, has only its workflow's. + */ +async function createWorkflowChat( + options: { archivedWorkflow?: boolean; legacyRow?: boolean } = {} +): Promise { const workflowId = generateId() const now = new Date() await db.insert(workflow).values({ @@ -41,7 +46,7 @@ async function createWorkflowChat(options: { archivedWorkflow?: boolean } = {}): .values({ userId: ownerId, workflowId, - workspaceId, + workspaceId: options.legacyRow ? null : workspaceId, type: 'copilot', }) .returning({ id: copilotChats.id }) @@ -99,11 +104,14 @@ describe('reading whether a chat is still streaming', () => { ).rejects.toThrow('Chat not found') }) - it('answers the owner of a workflow copilot chat', async () => { - const chatId = await createWorkflowChat() - await expect( - readChatStreamStatus.execute({ principal: owner, input: { chatId } }) - ).resolves.toEqual({ activeStreamId: null }) + it('answers the owner of a workflow copilot chat, including a legacy row without a workspace', async () => { + for (const chatId of [ + await createWorkflowChat(), + await createWorkflowChat({ legacyRow: true }), + ]) + await expect( + readChatStreamStatus.execute({ principal: owner, input: { chatId } }) + ).resolves.toEqual({ activeStreamId: null }) }) it('refuses a workflow copilot chat whose workflow is archived, as the chat detail read does', async () => { diff --git a/apps/sim/lib/mothership/chat/lifecycle.ts b/apps/sim/lib/mothership/chat/lifecycle.ts index a00130cd38e..bc596ab99b5 100644 --- a/apps/sim/lib/mothership/chat/lifecycle.ts +++ b/apps/sim/lib/mothership/chat/lifecycle.ts @@ -337,7 +337,7 @@ export async function authorizeChatWorkflowRead( /** A workspace Plan chat is unavailable while Plan mode is off for its owner. */ export async function isChatModeAvailable( - chat: { organizationId: string | null | undefined; mode: ConversationMode }, + chat: { organizationId?: string | null; mode: ConversationMode }, userId: string ): Promise { return chat.organizationId || chat.mode !== 'plan' ? true : isPlanModeEnabled(userId) diff --git a/apps/sim/lib/mothership/chat/transcript-pages.integration.ts b/apps/sim/lib/mothership/chat/transcript-pages.integration.ts index a24ece5795f..98916945163 100644 --- a/apps/sim/lib/mothership/chat/transcript-pages.integration.ts +++ b/apps/sim/lib/mothership/chat/transcript-pages.integration.ts @@ -124,8 +124,13 @@ async function createChat(type: 'mothership' | 'copilot' = 'mothership'): Promis return chat.id } -/** A workflow Copilot panel chat, as the panel stores it. */ -async function createWorkflowChat(options: { archivedWorkflow?: boolean } = {}): Promise { +/** + * A workflow Copilot panel chat, as the panel stores it. A legacy row, from before chats carried + * a workspace, has only its workflow's. + */ +async function createWorkflowChat( + options: { archivedWorkflow?: boolean; legacyRow?: boolean } = {} +): Promise { const workflowId = generateId() const now = new Date() await db.insert(workflow).values({ @@ -143,7 +148,7 @@ async function createWorkflowChat(options: { archivedWorkflow?: boolean } = {}): .values({ userId: ownerId, workflowId, - workspaceId, + workspaceId: options.legacyRow ? null : workspaceId, type: 'copilot', }) .returning({ id: copilotChats.id }) @@ -389,16 +394,22 @@ describe('chat transcript pages in PostgreSQL', () => { it('serves older pages of a workflow copilot chat while its owner may read the workflow', async () => { const chatId = await createWorkflowChat() + const legacyChatId = await createWorkflowChat({ legacyRow: true }) const archivedChatId = await createWorkflowChat({ archivedWorkflow: true }) - for (const id of [chatId, archivedChatId]) await appendTurns(id, 40) + for (const id of [chatId, legacyChatId, archivedChatId]) await appendTurns(id, 40) + + for (const id of [chatId, legacyChatId]) { + const cursor = (await latestWindow(id))?.olderCursor + if (!cursor) throw new Error('latest window reaches the first message') + const page = await readChatOlderMessages.execute({ + principal: owner, + input: { chatId: id, before: cursor }, + }) + expect(page.messages.length).toBeGreaterThan(0) + } const before = (await latestWindow(chatId))?.olderCursor if (!before) throw new Error('latest window reaches the first message') - const page = await readChatOlderMessages.execute({ - principal: owner, - input: { chatId, before }, - }) - expect(page.messages.length).toBeGreaterThan(0) await expect( readChatOlderMessages.execute({ principal: outsider, input: { chatId, before } }) ).rejects.toThrow('Chat not found') diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json index ba500819022..ca15838f317 100644 --- a/scripts/check-explicit-any.baseline.json +++ b/scripts/check-explicit-any.baseline.json @@ -1572,7 +1572,6 @@ "apps/sim/lib/mothership/async-runs/orphaned-runs.integration.ts": 10, "apps/sim/lib/mothership/auth/internal.ts": 1, "apps/sim/lib/mothership/billing/service-store.integration.ts": 3, - "apps/sim/lib/mothership/chat/application/context.ts": 1, "apps/sim/lib/mothership/chat/application/fork.ts": 2, "apps/sim/lib/mothership/chat/application/inline-images.ts": 1, "apps/sim/lib/mothership/chat/application/use-cases.test.ts": 1, From 62449f1a073054587d78ca1c9aa2dcfaea6332ec Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 15 Oct 2026 22:28:41 -0700 Subject: [PATCH 3/3] test(copilot): name each workflow chat fixture's workflow uniquely --- .../chat/application/read-stream-status.integration.ts | 2 +- apps/sim/lib/mothership/chat/transcript-pages.integration.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts b/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts index fd92b566eaf..8c63fecf9cd 100644 --- a/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts +++ b/apps/sim/lib/mothership/chat/application/read-stream-status.integration.ts @@ -35,7 +35,7 @@ async function createWorkflowChat( id: workflowId, userId: ownerId, workspaceId, - name: 'Chat stream status workflow', + name: `Chat stream status workflow ${workflowId}`, lastSynced: now, createdAt: now, updatedAt: now, diff --git a/apps/sim/lib/mothership/chat/transcript-pages.integration.ts b/apps/sim/lib/mothership/chat/transcript-pages.integration.ts index 98916945163..7531a85790e 100644 --- a/apps/sim/lib/mothership/chat/transcript-pages.integration.ts +++ b/apps/sim/lib/mothership/chat/transcript-pages.integration.ts @@ -137,7 +137,7 @@ async function createWorkflowChat( id: workflowId, userId: ownerId, workspaceId, - name: 'Transcript pages workflow', + name: `Transcript pages workflow ${workflowId}`, lastSynced: now, createdAt: now, updatedAt: now,