({
+ ...field,
+ value: edits[field.name] ?? field.value,
+ })),
+ }}
+ />
+ )}
{!action.account?.active && (
This account is no longer available.
)}
{review.query.isError && (
-
+
)}
{error?.proposalVersion === action.proposalVersion && (
diff --git a/apps/sim/components/secrets/organization-secrets-editor.tsx b/apps/sim/components/secrets/organization-secrets-editor.tsx
index 60ace35afef..e9f9bccd214 100644
--- a/apps/sim/components/secrets/organization-secrets-editor.tsx
+++ b/apps/sim/components/secrets/organization-secrets-editor.tsx
@@ -18,9 +18,10 @@ import {
interface OrganizationSecretsEditorProps {
mode: SecretSourceMode
+ backHref?: string
}
-export function OrganizationSecretsEditor({ mode }: OrganizationSecretsEditorProps) {
+export function OrganizationSecretsEditor({ mode, backHref }: OrganizationSecretsEditorProps) {
const { organization, viewer } = useOrganizationContext()
const router = useRouter()
const secrets = useOrganizationSecrets(organization.id, mode, mode === 'member' || viewer.isAdmin)
@@ -29,7 +30,7 @@ export function OrganizationSecretsEditor({ mode }: OrganizationSecretsEditorPro
const back =
mode === 'organization'
? { text: 'Sources', href: routes.settingsSection('integrations') }
- : { text: 'Integrations', href: routes.integrations }
+ : { text: 'Integrations', href: backHref ?? routes.integrations }
if (!secrets.data || secrets.error)
return (
Loading secrets…}
>
-
+
diff --git a/apps/sim/hooks/queries/assistant-actions.ts b/apps/sim/hooks/queries/assistant-actions.ts
index 72054f3accb..4819379f4b9 100644
--- a/apps/sim/hooks/queries/assistant-actions.ts
+++ b/apps/sim/hooks/queries/assistant-actions.ts
@@ -1,26 +1,47 @@
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useCallback } from 'react'
+import { compareStrings } from '@sim/utils/string'
+import {
+ type InfiniteData,
+ type QueryClient,
+ useInfiniteQuery,
+ useMutation,
+ useQueries,
+ useQuery,
+ useQueryClient,
+} from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
type AssistantActionDecisionBody,
+ type AssistantActionReceiptsResponse,
decideAssistantActionContract,
+ listAssistantActionReceiptsContract,
listAssistantActionsContract,
readAssistantToolPermissionsContract,
type UpdateAssistantToolPermissionsBody,
updateAssistantToolPermissionsContract,
} from '@/lib/api/contracts/assistant-actions'
-const assistantActionKeys = {
+export const assistantActionKeys = {
all: ['assistant-actions'] as const,
permissions: () => [...assistantActionKeys.all, 'permissions'] as const,
permission: (organizationId: string, credentialId: string) =>
[...assistantActionKeys.permissions(), organizationId, credentialId] as const,
review: (organizationId: string, chatId: string) =>
[...assistantActionKeys.all, 'review', organizationId, chatId] as const,
+ receipts: (organizationId: string, chatId: string) =>
+ [...assistantActionKeys.all, 'receipts', organizationId, chatId] as const,
+ turnReceipts: (organizationId: string, chatId: string, streamId: string) =>
+ [...assistantActionKeys.receipts(organizationId, chatId), 'turn', streamId] as const,
+ receiptBatches: (organizationId: string, chatId: string, streamId: string) =>
+ [...assistantActionKeys.receipts(organizationId, chatId), 'batches', streamId] as const,
+ receiptBatch: (organizationId: string, chatId: string, streamId: string, toolCallIds: string[]) =>
+ [...assistantActionKeys.receiptBatches(organizationId, chatId, streamId), toolCallIds] as const,
}
const ASSISTANT_PERMISSIONS_STALE_TIME = 10_000
const ASSISTANT_REVIEW_STALE_TIME = 3_000
const ASSISTANT_REVIEW_POLL_INTERVAL = 3_000
+const RECEIPT_BATCH_CONCURRENCY = 2
export function useAssistantToolPermissions(organizationId: string, credentialId: string) {
return useQuery({
@@ -70,10 +91,170 @@ export function useDecideAssistantAction(organizationId: string, chatId: string)
signal,
}),
onSettled: () =>
- client.invalidateQueries({ queryKey: assistantActionKeys.review(organizationId, chatId) }),
+ Promise.all([
+ client.invalidateQueries({ queryKey: assistantActionKeys.review(organizationId, chatId) }),
+ client.invalidateQueries({
+ queryKey: assistantActionKeys.receipts(organizationId, chatId),
+ refetchType: 'none',
+ }),
+ ]),
+ })
+}
+
+interface ReceiptTurnOptions {
+ organizationId: string
+ chatId: string
+ streamId: string
+ enabled: boolean
+ mountedTurnCount: number
+}
+
+interface ReceiptBatchOptions extends ReceiptTurnOptions {
+ candidateIds: Iterable
+ coveredIds: ReadonlySet
+ pageCount: number
+}
+
+/** Pages only mounted turns; an unresolved execution keeps polling after chat streaming stops. */
+export function useAssistantActionTurnReceipts({
+ organizationId,
+ chatId,
+ streamId,
+ active,
+ enabled,
+ mountedTurnCount,
+}: ReceiptTurnOptions & { active: boolean }) {
+ const client = useQueryClient()
+ return useInfiniteQuery({
+ queryKey: assistantActionKeys.turnReceipts(organizationId, chatId, streamId),
+ queryFn: ({ signal, pageParam }) =>
+ requestJson(listAssistantActionReceiptsContract, {
+ params: { id: organizationId, chatId },
+ query: { streamId, cursor: pageParam },
+ signal,
+ }),
+ initialPageParam: undefined as number | undefined,
+ getNextPageParam: (page) => page.nextCursor ?? undefined,
+ enabled: enabled && Boolean(organizationId && chatId && streamId),
+ staleTime: ASSISTANT_REVIEW_STALE_TIME,
+ refetchInterval: (query) =>
+ enabled &&
+ (active ||
+ query.state.data?.pages.some((page) => page.receipts.some((receipt) => receipt.unsettled)))
+ ? receiptPollInterval(
+ client,
+ organizationId,
+ chatId,
+ streamId,
+ query.state.data?.pages.length ?? 1,
+ mountedTurnCount
+ )
+ : false,
})
}
+/** Immutable batch keys retain recovery-only receipts after a decision removes the pending action. */
+export function useAssistantActionReceiptBatches({
+ organizationId,
+ chatId,
+ streamId,
+ candidateIds,
+ coveredIds,
+ enabled,
+ pageCount,
+ mountedTurnCount,
+}: ReceiptBatchOptions) {
+ const client = useQueryClient()
+ const existing = client.getQueriesData({
+ queryKey: assistantActionKeys.receiptBatches(organizationId, chatId, streamId),
+ })
+ const batches = existing.flatMap(([key]) => {
+ const ids = key.at(-1)
+ return Array.isArray(ids) && ids.every((id): id is string => typeof id === 'string')
+ ? [ids]
+ : []
+ })
+ const known = new Set([...coveredIds, ...batches.flat()])
+ const missing = [...new Set(candidateIds)].filter((id) => !known.has(id)).sort(compareStrings)
+ for (let index = 0; index < missing.length; index += 32)
+ batches.push(missing.slice(index, index + 32))
+ const interval = receiptPollInterval(
+ client,
+ organizationId,
+ chatId,
+ streamId,
+ pageCount,
+ mountedTurnCount
+ )
+ let initialRequests = 0
+ const queries = useQueries({
+ queries: batches.map((toolCallIds) => {
+ const key = assistantActionKeys.receiptBatch(organizationId, chatId, streamId, toolCallIds)
+ const state = client.getQueryState(key)
+ const started = state?.status === 'success' || state?.status === 'error'
+ const uncovered = toolCallIds.some((id) => !coveredIds.has(id))
+ const eligible = started || (uncovered && initialRequests++ < RECEIPT_BATCH_CONCURRENCY)
+ return {
+ queryKey: key,
+ queryFn: ({ signal }: { signal: AbortSignal }) =>
+ requestJson(listAssistantActionReceiptsContract, {
+ params: { id: organizationId, chatId },
+ query: { toolCallIds },
+ signal,
+ }),
+ enabled: enabled && eligible && Boolean(organizationId && chatId && streamId) && uncovered,
+ staleTime: ASSISTANT_REVIEW_STALE_TIME,
+ refetchInterval:
+ enabled &&
+ uncovered &&
+ existing
+ .find(([key]) => key.at(-1) === toolCallIds)?.[1]
+ ?.receipts.some((receipt) => receipt.unsettled && !coveredIds.has(receipt.toolCallId))
+ ? interval
+ : false,
+ }
+ }),
+ })
+ return batches.map((ids, index) => ({ ids, query: queries[index] }))
+}
+
+/** A review batch refreshes receipts once, after all of its decisions settle. */
+export function useRefreshAssistantActionReceipts(organizationId: string, chatId: string) {
+ const client = useQueryClient()
+ return useCallback(
+ () =>
+ client.invalidateQueries({ queryKey: assistantActionKeys.receipts(organizationId, chatId) }),
+ [client, organizationId, chatId]
+ )
+}
+
+function receiptPollInterval(
+ client: QueryClient,
+ organizationId: string,
+ chatId: string,
+ streamId: string,
+ pageCount: number,
+ mountedTurnCount: number
+) {
+ const pages = client.getQueryData>(
+ assistantActionKeys.turnReceipts(organizationId, chatId, streamId)
+ )
+ const covered = new Set(
+ pages?.pages.flatMap((page) => page.receipts.map((receipt) => receipt.toolCallId))
+ )
+ const batches = client
+ .getQueriesData({
+ queryKey: assistantActionKeys.receiptBatches(organizationId, chatId, streamId),
+ })
+ .filter(([, data]) =>
+ data?.receipts.some((receipt) => receipt.unsettled && !covered.has(receipt.toolCallId))
+ ).length
+ return Math.max(
+ ASSISTANT_REVIEW_POLL_INTERVAL,
+ (pageCount + batches) * Math.max(1, mountedTurnCount) * 1_000
+ )
+}
+
export function useUpdateAssistantToolPermissions(organizationId: string, credentialId: string) {
const client = useQueryClient()
return useMutation({
diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts
index 6fe908733ed..54765a07de1 100644
--- a/apps/sim/hooks/queries/organization-accounts.ts
+++ b/apps/sim/hooks/queries/organization-accounts.ts
@@ -45,6 +45,7 @@ import {
import { connectCredentialGroupInPopup } from '@/lib/credential-groups/oauth-popup'
import { isDesktopApp } from '@/lib/desktop'
import { connectDesktopSource } from '@/lib/desktop/source-connect'
+import { assistantActionKeys } from '@/hooks/queries/assistant-actions'
import { personalCredentialKeys } from '@/hooks/queries/personal-credentials'
import { mcpKeys } from '@/hooks/queries/utils/mcp-keys'
import { resetOrganizationSearchAccess } from '@/hooks/queries/utils/reset-organization-search-access'
@@ -249,6 +250,7 @@ export function useUpdateOrganizationAccounts() {
async function refreshAccounts(client: ReturnType) {
await Promise.all([
client.invalidateQueries({ queryKey: organizationAccountsKeys.all }),
+ client.invalidateQueries({ queryKey: assistantActionKeys.permissions() }),
client.invalidateQueries({ queryKey: personalCredentialKeys.lists() }),
client.invalidateQueries({ queryKey: mcpKeys.managedCatalog() }),
invalidateSelectorQueries(client),
diff --git a/apps/sim/lib/api/contracts/assistant-actions.ts b/apps/sim/lib/api/contracts/assistant-actions.ts
index 112a7518a75..5c0e6306494 100644
--- a/apps/sim/lib/api/contracts/assistant-actions.ts
+++ b/apps/sim/lib/api/contracts/assistant-actions.ts
@@ -96,6 +96,7 @@ export const listAssistantActionsContract = defineRouteContract({
.array(
z.object({
toolCallId: z.string().min(1).max(256),
+ sequence: z.number().int().nonnegative().nullish(),
runId: z.uuid(),
toolId: toolIdSchema,
name: z.string().max(512),
@@ -157,3 +158,59 @@ export const decideAssistantActionContract = defineRouteContract({
export type AssistantActionsResponse = z.output
export type AssistantActionDecisionBody = z.input
+
+export const listAssistantActionReceiptsContract = defineRouteContract({
+ method: 'GET',
+ path: `${reviewPath}/receipts`,
+ params: reviewParamsSchema,
+ query: z.union([
+ z
+ .object({
+ streamId: z.string().min(1).max(256),
+ cursor: z.coerce.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
+ })
+ .strict(),
+ z
+ .object({
+ toolCallIds: z
+ .union([toolIdSchema, z.array(toolIdSchema).min(1).max(32)])
+ .transform((ids) => [...new Set(typeof ids === 'string' ? [ids] : ids)]),
+ })
+ .strict(),
+ ]),
+ response: {
+ mode: 'json',
+ schema: z.object({
+ receipts: z
+ .array(
+ listAssistantActionsContract.response.schema.shape.actions.element
+ .omit({ proposalVersion: true })
+ .extend({
+ sequence: z.number().int().nonnegative().nullable(),
+ streamId: z.string().min(1).max(256),
+ state: z.enum([
+ 'pending',
+ 'approved',
+ 'running',
+ 'succeeded',
+ 'cancelled',
+ 'failed',
+ 'uncertain',
+ ]),
+ unsettled: z.boolean(),
+ decision: z.enum(['allow', 'skip', 'allow_chat', 'always_allow']).nullable(),
+ decidedAt: z.iso.datetime().nullable(),
+ completedAt: z.iso.datetime().nullable(),
+ detailsAvailable: z.boolean(),
+ warning: z.string().max(2048).nullable(),
+ })
+ )
+ .max(32),
+ nextCursor: z.number().int().nonnegative().nullable(),
+ }),
+ },
+})
+
+export type AssistantActionReceiptsResponse = z.output<
+ typeof listAssistantActionReceiptsContract.response.schema
+>
diff --git a/apps/sim/lib/assistant-actions/application/operations.ts b/apps/sim/lib/assistant-actions/application/operations.ts
index 3e86b5ee1d3..2a7d37d16be 100644
--- a/apps/sim/lib/assistant-actions/application/operations.ts
+++ b/apps/sim/lib/assistant-actions/application/operations.ts
@@ -1,6 +1,13 @@
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'
export const assistantActionOperations = {
+ // permission-group-exempt: reads the owner's existing action history without granting tool access.
+ receipts: defineOrganizationOperation({
+ id: 'assistant_actions.receipts',
+ minimumRole: 'member',
+ capability: 'none',
+ principalKinds: ['session'],
+ }),
list: defineOrganizationOperation({
id: 'assistant_actions.list',
minimumRole: 'member',
diff --git a/apps/sim/lib/assistant-actions/application/receipts.ts b/apps/sim/lib/assistant-actions/application/receipts.ts
new file mode 100644
index 00000000000..8f7bd7dca40
--- /dev/null
+++ b/apps/sim/lib/assistant-actions/application/receipts.ts
@@ -0,0 +1,179 @@
+import { db } from '@sim/db'
+import { copilotAsyncToolCalls, copilotChats, copilotRuns } from '@sim/db/schema'
+import { truncateAtCodePoint } from '@sim/utils/string'
+import { and, asc, eq, gt, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'
+import type { AssistantActionReceiptsResponse } from '@/lib/api/contracts/assistant-actions'
+import { assistantActionOperations } from '@/lib/assistant-actions/application/operations'
+import { assistantReviewFields } from '@/lib/assistant-actions/proposal'
+import { loadAssistantAccount } from '@/lib/assistant-actions/tool-permissions'
+import {
+ defineAuthorizedOrganizationUseCase,
+ type OrganizationUseCaseContext,
+} from '@/lib/core/application/authorized-organization-use-case'
+import { OrchestrationError } from '@/lib/core/orchestration/types'
+import { TERMINAL_RUN_STATUSES } from '@/lib/mothership/async-runs/repository'
+import {
+ requireOwnedChatReadable,
+ resolveOwnedChatContext,
+} from '@/lib/mothership/chat/application/context'
+import { toolStartWindowClosed } from '@/lib/mothership/request/tools/deadline'
+import { getToolMetadata } from '@/tools/metadata'
+
+type ReceiptInput = { organizationId: string; chatId: string } & (
+ | { streamId: string; cursor?: number }
+ | { toolCallIds: string[] }
+)
+type ReceiptState = AssistantActionReceiptsResponse['receipts'][number]['state']
+
+const receiptColumns = {
+ toolCallId: copilotAsyncToolCalls.toolCallId,
+ runId: copilotAsyncToolCalls.runId,
+ streamId: copilotRuns.streamId,
+ currentStreamId: copilotChats.conversationId,
+ runStatus: copilotRuns.status,
+ closedAt: copilotRuns.toolAdmissionClosedAt,
+ executableName: copilotAsyncToolCalls.executableName,
+ toolName: copilotAsyncToolCalls.toolName,
+ args: sql`coalesce(${copilotAsyncToolCalls.approvedArgs}, ${copilotAsyncToolCalls.args})`,
+ binding: copilotAsyncToolCalls.assistantAction,
+ status: copilotAsyncToolCalls.status,
+ decision: copilotAsyncToolCalls.permissionDecision,
+ requestedAt: copilotAsyncToolCalls.permissionRequestedAt,
+ decidedAt: copilotAsyncToolCalls.permissionDecidedAt,
+ completedAt: copilotAsyncToolCalls.completedAt,
+ persistSeq: copilotAsyncToolCalls.persistSeq,
+ startedAt: copilotAsyncToolCalls.executionStartedAt,
+ settledAt: copilotAsyncToolCalls.executionSettledAt,
+ revokedAt: copilotAsyncToolCalls.executionRevokedAt,
+ leaseExpiresAt: copilotAsyncToolCalls.executionLeaseExpiresAt,
+ deadlineAt: copilotAsyncToolCalls.executionDeadlineAt,
+ outcomeUnknown: sql`coalesce(${copilotAsyncToolCalls.result}->'outcomeUnknown' = 'true'::jsonb, false) or coalesce(${copilotAsyncToolCalls.result}#>'{output,outcomeUnknown}' = 'true'::jsonb, false)`,
+ warning: sql<
+ string | null
+ >`case when ${copilotAsyncToolCalls.executableName} = 'jira_write' then left(${copilotAsyncToolCalls.result}#>>'{output,warning}', 2048) else null end`,
+}
+
+/** History remains readable after a turn closes; it never grants authority to execute it. */
+export const listOrganizationAssistantActionReceipts = defineAuthorizedOrganizationUseCase({
+ operation: assistantActionOperations.receipts,
+ async authorizeResource({ principal, input, context }: OrganizationUseCaseContext) {
+ const chat = await resolveOwnedChatContext(principal, input.chatId)
+ if (chat.organizationId !== context.organizationId || chat.mode !== 'assistant')
+ throw new OrchestrationError('not_found', 'Chat not found')
+ await requireOwnedChatReadable(chat)
+ },
+ async execute({
+ input,
+ context,
+ }: OrganizationUseCaseContext): Promise {
+ const records = await db
+ .select(receiptColumns)
+ .from(copilotAsyncToolCalls)
+ .innerJoin(copilotRuns, eq(copilotRuns.id, copilotAsyncToolCalls.runId))
+ .innerJoin(copilotChats, eq(copilotChats.id, copilotRuns.chatId))
+ .where(
+ and(
+ eq(copilotRuns.chatId, input.chatId),
+ eq(copilotRuns.organizationId, context.organizationId),
+ eq(copilotRuns.userId, context.userId),
+ eq(copilotChats.organizationId, context.organizationId),
+ eq(copilotChats.userId, context.userId),
+ isNull(copilotChats.deletedAt),
+ isNotNull(copilotAsyncToolCalls.assistantAction),
+ or(
+ isNotNull(copilotAsyncToolCalls.permissionRequestedAt),
+ sql`${copilotAsyncToolCalls.assistantAction}->>'effect' = 'write'`
+ ),
+ 'streamId' in input
+ ? eq(copilotRuns.streamId, input.streamId)
+ : inArray(copilotAsyncToolCalls.toolCallId, input.toolCallIds),
+ 'cursor' in input && input.cursor !== undefined
+ ? gt(copilotAsyncToolCalls.persistSeq, input.cursor)
+ : undefined
+ )
+ )
+ .orderBy(asc(copilotAsyncToolCalls.persistSeq))
+ .limit(33)
+ const page = records.slice(0, 32)
+ const credentialIds = [
+ ...new Set(page.flatMap((record) => (record.binding ? [record.binding.credentialId] : []))),
+ ]
+ const accounts = new Map(
+ await Promise.all(
+ credentialIds.map(
+ async (credentialId) =>
+ [credentialId, await loadAssistantAccount({ ...context, credentialId })] as const
+ )
+ )
+ )
+ const now = Date.now()
+ const receipts = page.flatMap((record) => {
+ if (!record.binding) return []
+ const account = accounts.get(record.binding.credentialId)
+ const toolId = record.executableName ?? record.toolName
+ const tool = getToolMetadata(toolId)
+ const detailsAvailable = Boolean(
+ tool?.assistant && tool.assistant.version === record.binding.operationVersion
+ )
+ const running =
+ !record.settledAt &&
+ !record.revokedAt &&
+ record.leaseExpiresAt !== null &&
+ record.leaseExpiresAt.getTime() > now
+ const closed =
+ record.closedAt !== null ||
+ TERMINAL_RUN_STATUSES.includes(record.runStatus) ||
+ record.currentStreamId !== record.streamId ||
+ toolStartWindowClosed(record.deadlineAt?.getTime(), now)
+ let state: ReceiptState
+ if (record.outcomeUnknown) state = 'uncertain'
+ else if (record.status === 'completed') state = 'succeeded'
+ else if (
+ (record.startedAt && record.revokedAt) ||
+ (record.startedAt && record.status === 'cancelled')
+ )
+ state = 'uncertain'
+ else if (record.decision === 'skip' || record.status === 'cancelled') state = 'cancelled'
+ else if (record.status === 'failed') state = 'failed'
+ else if (running) state = 'running'
+ else if (record.startedAt || record.status === 'delivered') state = 'uncertain'
+ else if (closed) state = 'cancelled'
+ else state = record.decision || record.requestedAt === null ? 'approved' : 'pending'
+ return [
+ {
+ toolCallId: record.toolCallId,
+ sequence: record.persistSeq,
+ runId: record.runId,
+ streamId: record.streamId,
+ toolId,
+ name: tool?.name ?? record.toolName,
+ effect: record.binding.effect,
+ state,
+ unsettled:
+ running ||
+ state === 'approved' ||
+ state === 'pending' ||
+ (!closed &&
+ !record.settledAt &&
+ !record.revokedAt &&
+ ['pending', 'running', 'delivered'].includes(record.status)),
+ decision: record.decision,
+ decidedAt: record.decidedAt?.toISOString() ?? null,
+ completedAt: record.completedAt?.toISOString() ?? null,
+ detailsAvailable,
+ warning: record.warning === null ? null : truncateAtCodePoint(record.warning, 2048, ''),
+ account: account
+ ? {
+ credentialId: account.credentialId,
+ displayName: account.displayName,
+ providerId: account.providerId,
+ active: account.active,
+ }
+ : null,
+ fields: detailsAvailable ? assistantReviewFields(record) : [],
+ },
+ ]
+ })
+ return { receipts, nextCursor: records.length > 32 ? (page.at(-1)?.persistSeq ?? null) : null }
+ },
+})
diff --git a/apps/sim/lib/assistant-actions/application/review.ts b/apps/sim/lib/assistant-actions/application/review.ts
index 97775bfaa65..6716e59c4c1 100644
--- a/apps/sim/lib/assistant-actions/application/review.ts
+++ b/apps/sim/lib/assistant-actions/application/review.ts
@@ -103,6 +103,7 @@ export const listOrganizationAssistantActions = defineAuthorizedOrganizationUseC
const tool = getToolMetadata(record.executableName ?? '')
return {
toolCallId: record.toolCallId,
+ sequence: record.persistSeq,
runId: record.runId,
toolId: record.executableName ?? record.toolName,
name: tool?.name ?? record.toolName,
diff --git a/apps/sim/lib/assistant-actions/proposal.ts b/apps/sim/lib/assistant-actions/proposal.ts
index abd006ab5aa..eb70e90a04c 100644
--- a/apps/sim/lib/assistant-actions/proposal.ts
+++ b/apps/sim/lib/assistant-actions/proposal.ts
@@ -57,7 +57,7 @@ export function applyAssistantReviewEdits(
}
/** Every supplied non-authentication argument remains visible, even without a custom review label. */
-export function assistantReviewFields(record: ActionRecord) {
+export function assistantReviewFields(record: Pick) {
const tool = getToolMetadata(record.executableName ?? '')
const args = toRecord(record.args)
const declared = tool?.assistant?.reviewFields ?? []
diff --git a/apps/sim/lib/mothership/chat/display-message.ts b/apps/sim/lib/mothership/chat/display-message.ts
index 6454d94fc20..cd7cbb98c35 100644
--- a/apps/sim/lib/mothership/chat/display-message.ts
+++ b/apps/sim/lib/mothership/chat/display-message.ts
@@ -206,6 +206,7 @@ export function toDisplayMessage(msg: PersistedMessage): ChatMessage {
const display: ChatMessage = {
id: msg.id,
+ ...(msg.streamId ? { streamId: msg.streamId } : {}),
role: msg.role,
...(msg.origin ? { origin: msg.origin } : {}),
content: msg.content,
diff --git a/apps/sim/lib/mothership/chat/effective-transcript.ts b/apps/sim/lib/mothership/chat/effective-transcript.ts
index 754ed08da1f..5e664ccffe7 100644
--- a/apps/sim/lib/mothership/chat/effective-transcript.ts
+++ b/apps/sim/lib/mothership/chat/effective-transcript.ts
@@ -549,6 +549,7 @@ export function buildLiveAssistantMessage(params: {
return normalizeMessage({
id: getLiveAssistantMessageId(streamId),
+ streamId,
role: 'assistant',
content: runningText,
timestamp: lastTimestamp ?? new Date().toISOString(),
diff --git a/apps/sim/lib/mothership/chat/lifecycle.ts b/apps/sim/lib/mothership/chat/lifecycle.ts
index a873e538ca4..915639411dc 100644
--- a/apps/sim/lib/mothership/chat/lifecycle.ts
+++ b/apps/sim/lib/mothership/chat/lifecycle.ts
@@ -127,12 +127,19 @@ export async function loadCopilotChatMessages(
const rows = await db
.select({
content: options?.projection === 'display' ? displayMessageContent : copilotMessages.content,
+ streamId: copilotMessages.streamId,
})
.from(copilotMessages)
.where(and(eq(copilotMessages.chatId, chatId), isNull(copilotMessages.deletedAt)))
.orderBy(...TRANSCRIPT_ORDER)
// Also strip on read: rows written before the backfill still carry outputs.
- return rows.map((row) => stripToolResultOutput(row.content as PersistedMessage))
+ return rows.map((row) =>
+ stripToolResultOutput(
+ options?.projection === 'display'
+ ? { ...(row.content as PersistedMessage), streamId: row.streamId ?? undefined }
+ : (row.content as PersistedMessage)
+ )
+ )
}
/** Messages in the newest window the chat UI opens on. */
@@ -178,9 +185,10 @@ async function loadCopilotChatTranscriptPage(
createdAt: preciseTimestampText(copilotMessages.createdAt),
role: copilotMessages.role,
content: displayMessageContent,
+ streamId: copilotMessages.streamId,
}
- let rows: Array
+ let rows: Array
let hasOlder: boolean
if (window.kind === 'from') {
const [page, older] = await Promise.all([
@@ -219,7 +227,12 @@ async function loadCopilotChatTranscriptPage(
: window.position
const windowStartCursor = windowStart ? encodeTranscriptCursor(windowStart) : null
return {
- messages: rows.map((row) => stripToolResultOutput(row.content as PersistedMessage)),
+ messages: rows.map((row) =>
+ stripToolResultOutput({
+ ...(row.content as PersistedMessage),
+ streamId: row.streamId ?? undefined,
+ })
+ ),
olderCursor: hasOlder ? windowStartCursor : null,
windowStartCursor,
}
diff --git a/apps/sim/lib/mothership/chat/messages-store.ts b/apps/sim/lib/mothership/chat/messages-store.ts
index 31468727f21..727af290c36 100644
--- a/apps/sim/lib/mothership/chat/messages-store.ts
+++ b/apps/sim/lib/mothership/chat/messages-store.ts
@@ -31,7 +31,8 @@ function toRow(
options?: { chatModel?: string | null; streamId?: string | null }
): typeof copilotMessages.$inferInsert {
const ts = new Date(message.timestamp)
- const content = stripToolResultOutput(message)
+ const { streamId, ...storedMessage } = message
+ const content = stripToolResultOutput(storedMessage)
return {
chatId,
messageId: message.id,
diff --git a/apps/sim/lib/mothership/chat/persisted-message.ts b/apps/sim/lib/mothership/chat/persisted-message.ts
index 761859ec9fb..b4fc382acb9 100644
--- a/apps/sim/lib/mothership/chat/persisted-message.ts
+++ b/apps/sim/lib/mothership/chat/persisted-message.ts
@@ -133,6 +133,8 @@ export function copyPersistedMessageContext(c: PersistedMessageContext): Persist
export interface PersistedMessage {
id: string
+ /** Server-owned turn association for display; never persisted in message content. */
+ streamId?: string
role: 'user' | 'assistant'
requestMode?: 'agent' | 'assistant' | 'plan'
content: string
@@ -896,6 +898,7 @@ export function normalizeMessage(raw: Record): PersistedMessage
if (raw.requestId && typeof raw.requestId === 'string') {
msg.requestId = raw.requestId
}
+ if (typeof raw.streamId === 'string') msg.streamId = raw.streamId
const origin = TurnOrigin.safeParse(raw.origin)
if (origin.success) msg.origin = origin.data
diff --git a/apps/sim/scripts/test-assistant-actions-e2e.ts b/apps/sim/scripts/test-assistant-actions-e2e.ts
index 3d8dde08afc..2457d8b8d07 100644
--- a/apps/sim/scripts/test-assistant-actions-e2e.ts
+++ b/apps/sim/scripts/test-assistant-actions-e2e.ts
@@ -7,13 +7,15 @@ import { createLogger } from '@sim/logger'
import { createDeferred } from '@sim/testing/helpers/deferred'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId, generateShortId } from '@sim/utils/id'
-import { toRecord } from '@sim/utils/object'
+import { toArray, toRecord } from '@sim/utils/object'
import { makeSignature } from 'better-auth/crypto'
import postgres from 'postgres'
import {
+ listAssistantActionReceiptsContract,
listAssistantActionsContract,
readAssistantToolPermissionsContract,
} from '@/lib/api/contracts/assistant-actions'
+import { getOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts'
import { closeRedisConnection } from '@/lib/core/config/redis'
import {
acquirePendingChatStream,
@@ -59,6 +61,10 @@ const organizationId = generateId()
const otherOrganizationId = generateId()
const assistantId = generateId()
const credentialId = generateId()
+const secondCredentialId = generateId()
+const codaServerId = generateId()
+const firefliesServerId = generateId()
+const firefliesCredentialId = `mcp-cg-${generateId()}`
const nativeCredentialId = generateId()
const groupId = generateId()
const enrollmentId = generateId()
@@ -73,6 +79,9 @@ const lagStreamId = generateId()
const lagFirstCallId = generateId()
const lagSecondCallId = generateId()
const lateCallId = generateId()
+const bulkChatId = generateId()
+const bulkStreamId = generateId()
+const bulkRunId = generateId()
const reviewChatId = generateId()
const reviewRunId = generateId()
const reviewStreamId = generateId()
@@ -87,6 +96,7 @@ const requestId = generateId()
const firstCallId = generateId()
const secondCallId = generateId()
const fixtureStreams = [
+ { chatId: bulkChatId, streamId: bulkStreamId },
{ chatId: assistantId, streamId },
{ chatId: lagChatId, streamId: lagStreamId },
{ chatId: reviewChatId, streamId: reviewStreamId },
@@ -119,13 +129,18 @@ const answer = 'Review the two Jira issues before I create them.'
const reviewAnswer = 'Review these keyboard and batch proposals.'
const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] =
[]
+const browserMessages: { type: string; text: string }[] = []
const browserErrors: string[] = []
const serverErrors: { path: string; status: number }[] = []
const httpResponses: { method: string; path: string; status: number }[] = []
const browser = await chromium.launch()
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
const page = await context.newPage()
-const reviewCards = page.getByRole('region', { name: 'Review Jira Write', exact: true })
+const reviewCards = page.getByRole('region', { name: 'Review Create Jira issue', exact: true })
+page.on('console', (message) => {
+ if (browserMessages.length < 40)
+ browserMessages.push({ type: message.type(), text: message.text().slice(0, 1000) })
+})
page.on('pageerror', (error) => browserErrors.push(error.message))
page.on('response', (response) => {
const url = new URL(response.url())
@@ -233,13 +248,37 @@ try {
await tx`insert into session (id, token, user_id, expires_at, created_at, updated_at)
values (${generateId()}, ${token}, ${userId}, now() + interval '1 day', now(), now())`
await tx`insert into credential_group (id, organization_id, public_id, name, options)
- values (${groupId}, ${organizationId}, ${generateId()}, 'Personal Jira', ${tx.json(['jira', 'salesforce', ...catalogAccounts.map((account) => account.option)].map((provider) => ({ id: provider, provider, label: provider, authorizationAppId: 'fixture', requiredScopes: [], scopeVersion: 1, required: false, status: 'active' })))})`
+ values (${groupId}, ${organizationId}, ${generateId()}, 'Personal Jira', ${tx.json([{ id: 'jira', provider: 'jira' }, { id: 'jira-team', provider: 'jira' }, { id: 'salesforce', provider: 'salesforce' }, ...catalogAccounts.map((account) => ({ id: account.option, provider: account.option }))].map(({ id, provider }) => ({ id, provider, label: id, authorizationAppId: 'fixture', requiredScopes: [], scopeVersion: 1, required: false, status: 'active' })))})`
await tx`insert into resource_policy (id, organization_id, resource_type, resource_id, document, created_by, updated_by)
values (${generateId()}, ${organizationId}, 'credential_group', ${groupId}, ${tx.json({ version: 2, resource: { type: 'credential_group', id: groupId }, statements: [] })}, ${userId}, ${userId})`
await tx`insert into credential_group_enrollment (id, credential_group_id, user_id, email, status, invitation_token_hash, invitation_expires_at, invited_at)
values (${enrollmentId}, ${groupId}, ${userId}, ${email}, 'completed', ${enrollmentId.replaceAll('-', '').repeat(2)}, now() + interval '1 day', now())`
+ for (const [id, provider] of [
+ [codaServerId, 'coda'],
+ [firefliesServerId, 'fireflies'],
+ ] as const) {
+ await tx`insert into mcp_servers (id, organization_id, credential_group_id, managed_connector_id, name, transport, auth_type, url)
+ values (${id}, ${organizationId}, ${groupId}, ${provider}, ${provider}, 'streamable-http', 'oauth', 'https://example.invalid/mcp')`
+ }
+ await tx`insert into credential (id, organization_id, type, display_name, credential_group_enrollment_id, mcp_server_id, managed_oauth_status, encrypted_oauth_token_set, mcp_tools, granted_at)
+ values (${firefliesCredentialId}, ${organizationId}, 'managed_mcp', 'meetings@acme.example', ${enrollmentId}, ${firefliesServerId}, 'active', 'fixture-not-a-provider-token', '[]'::jsonb, now())`
+ for (const source of [
+ 'coda',
+ 'jira',
+ 'gmail',
+ 'google_calendar',
+ 'google_drive',
+ 'confluence',
+ 'github',
+ 'google_meet',
+ ]) {
+ await tx`insert into organization_search_integration (organization_id, connector_type, approved)
+ values (${organizationId}, ${source}, true)`
+ }
await tx`insert into credential (id, organization_id, type, created_by, display_name, provider_id, credential_group_enrollment_id, credential_group_option_id, managed_oauth_status, managed_oauth_scope_version, granted_scopes, provider_subject_id, authorization_app_id, encrypted_oauth_token_set, granted_at)
values (${credentialId}, ${organizationId}, 'managed_oauth', ${userId}, 'taylor@acme.example', 'jira', ${enrollmentId}, 'jira', 'active', 1, '{}'::text[], ${userId}, 'fixture', 'fixture-not-a-provider-token', now())`
+ await tx`insert into credential (id, organization_id, type, created_by, display_name, provider_id, credential_group_enrollment_id, credential_group_option_id, managed_oauth_status, managed_oauth_scope_version, granted_scopes, provider_subject_id, authorization_app_id, encrypted_oauth_token_set, granted_at)
+ values (${secondCredentialId}, ${organizationId}, 'managed_oauth', ${userId}, 'launch-team@acme.example', 'jira', ${enrollmentId}, 'jira-team', 'active', 1, '{}'::text[], ${secondCredentialId}, 'fixture', 'fixture-not-a-provider-token', now())`
await tx`insert into credential (id, organization_id, type, created_by, display_name, provider_id, credential_group_enrollment_id, credential_group_option_id, managed_oauth_status, managed_oauth_scope_version, granted_scopes, provider_subject_id, authorization_app_id, encrypted_oauth_token_set, granted_at)
values (${nativeCredentialId}, ${organizationId}, 'managed_oauth', ${userId}, 'taylor@acme.example', 'salesforce', ${enrollmentId}, 'salesforce', 'active', 1, '{}'::text[], ${userId}, 'fixture', 'fixture-not-a-provider-token', now())`
for (const account of catalogAccounts) {
@@ -247,7 +286,8 @@ try {
values (${account.credentialId}, ${organizationId}, 'managed_oauth', ${userId}, 'taylor@acme.example', ${account.provider}, ${enrollmentId}, ${account.option}, 'active', 1, '{}'::text[], ${userId}, 'fixture', 'fixture-not-a-provider-token', now())`
}
await tx`insert into copilot_chats (id, user_id, organization_id, type, title, conversation_id, config)
- values (${assistantId}, ${userId}, ${organizationId}, 'mothership', 'Launch follow-up', ${streamId}, ${tx.json({ conversationMode: 'assistant' })}),
+ values (${bulkChatId}, ${userId}, ${organizationId}, 'mothership', 'Paginated action review', ${bulkStreamId}, ${tx.json({ conversationMode: 'assistant' })}),
+ (${assistantId}, ${userId}, ${organizationId}, 'mothership', 'Launch follow-up', ${streamId}, ${tx.json({ conversationMode: 'assistant' })}),
(${lagChatId}, ${userId}, ${organizationId}, 'mothership', 'Delayed action transcript', ${lagStreamId}, ${tx.json({ conversationMode: 'assistant' })}),
(${reviewChatId}, ${userId}, ${organizationId}, 'mothership', 'Keyboard and batch review', ${reviewStreamId}, ${tx.json({ conversationMode: 'assistant' })})`
const oldResponseId = generateId()
@@ -286,12 +326,14 @@ try {
values (${generateId()}, ${assistantId}, ${data.id}, ${data.role}, ${stream}, ${tx.json({ ...content, requestId, timestamp: new Date().toISOString() })}, ${index + 1})`
}
await tx`insert into copilot_messages (id, chat_id, message_id, role, stream_id, content, seq)
- values (${generateId()}, ${lagChatId}, ${lagStreamId}, 'user', ${lagStreamId}, ${tx.json({ id: lagStreamId, role: 'user', content: 'Create two more Jira issues.', requestId, timestamp: new Date().toISOString() })}, 1),
+ values (${generateId()}, ${bulkChatId}, ${bulkStreamId}, 'user', ${bulkStreamId}, ${tx.json({ id: bulkStreamId, role: 'user', content: 'Review this batch of Jira issues.', requestId, timestamp: new Date().toISOString() })}, 1),
+ (${generateId()}, ${lagChatId}, ${lagStreamId}, 'user', ${lagStreamId}, ${tx.json({ id: lagStreamId, role: 'user', content: 'Create two more Jira issues.', requestId, timestamp: new Date().toISOString() })}, 1),
(${generateId()}, ${reviewChatId}, ${reviewStreamId}, 'user', ${reviewStreamId}, ${tx.json({ id: reviewStreamId, role: 'user', content: 'Prepare follow-up actions for review.', requestId, timestamp: new Date().toISOString() })}, 1)`
await tx`insert into copilot_runs (id, chat_id, user_id, organization_id, execution_id, stream_id, tool_execution_version, status, started_at, completed_at, tool_admission_closed_at)
values (${previousRunId}, ${assistantId}, ${userId}, ${organizationId}, ${generateId()}, ${previousStreamId}, 3, 'complete', now() - interval '1 hour', now() - interval '59 minutes', now() - interval '59 minutes')`
await tx`insert into copilot_runs (id, chat_id, user_id, organization_id, execution_id, stream_id, tool_execution_version, status)
- values (${runId}, ${assistantId}, ${userId}, ${organizationId}, ${generateId()}, ${streamId}, 3, 'paused_waiting_for_tool'),
+ values (${bulkRunId}, ${bulkChatId}, ${userId}, ${organizationId}, ${generateId()}, ${bulkStreamId}, 3, 'paused_waiting_for_tool'),
+ (${runId}, ${assistantId}, ${userId}, ${organizationId}, ${generateId()}, ${streamId}, 3, 'paused_waiting_for_tool'),
(${lagRunId}, ${lagChatId}, ${userId}, ${organizationId}, ${generateId()}, ${lagStreamId}, 3, 'paused_waiting_for_tool'),
(${reviewRunId}, ${reviewChatId}, ${userId}, ${organizationId}, ${generateId()}, ${reviewStreamId}, 3, 'paused_waiting_for_tool')`
})
@@ -329,26 +371,207 @@ try {
sameSite: 'Lax',
},
])
+ await check(
+ 'Receipts restore actions before the transcript arrives and conceal foreign turns',
+ async () => {
+ const url = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${assistantId}/actions/receipts`
+ const response = await context.request.get(url, { params: { streamId } })
+ assert.equal(response.status(), 200, await response.text())
+ const result = listAssistantActionReceiptsContract.response.schema.parse(
+ await response.json()
+ )
+ assert.deepEqual(
+ result.receipts.map((receipt) => receipt.toolCallId),
+ [firstCallId, secondCallId]
+ )
+ assert(result.receipts.every((receipt) => receipt.state === 'pending'))
+ assert(
+ result.receipts.every((receipt) =>
+ receipt.fields.every((field) => field.name !== 'credential')
+ )
+ )
+ const foreign = await context.request.get(url, { params: { streamId: lagStreamId } })
+ assert.equal(foreign.status(), 200)
+ assert.deepEqual(
+ listAssistantActionReceiptsContract.response.schema.parse(await foreign.json()).receipts,
+ []
+ )
+ const overflow = new URL(url)
+ for (let i = 0; i < 33; i++) overflow.searchParams.append('toolCallIds', `call-${i}`)
+ assert.equal((await context.request.get(overflow.toString())).status(), 400)
+ }
+ )
+ await check(
+ 'Historical receipts distinguish approval, completion, cancellation, and uncertain outcomes',
+ async () => {
+ await insertProposal(previousRunId, previousCallId, 'Original historical title')
+ await sql`update copilot_async_tool_calls set status = 'completed', permission_decision = 'allow', permission_decided_at = now(), approved_args = ${sql.json(proposalArguments('Approved historical title'))}, execution_started_at = now(), execution_settled_at = now(), completed_at = now(), result = ${sql.json({ output: { warning: 'Issue created; assignment needs attention. Do not recreate the issue.', privatePayload: 'not-for-the-receipt'.repeat(20_000) } })} where tool_call_id = ${previousCallId}`
+ const url = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${assistantId}/actions/receipts`
+ const response = await context.request.get(url, { params: { toolCallIds: previousCallId } })
+ assert.equal(response.status(), 200, await response.text())
+ const result = listAssistantActionReceiptsContract.response.schema.parse(
+ await response.json()
+ )
+ const receipt = result.receipts[0]
+ assert(receipt)
+ assert.equal(receipt.state, 'succeeded')
+ assert.equal(
+ receipt.fields.find((field) => field.name === 'summary')?.value,
+ 'Approved historical title'
+ )
+ assert.equal(
+ receipt.warning,
+ 'Issue created; assignment needs attention. Do not recreate the issue.'
+ )
+ assert(!JSON.stringify(result).includes('privatePayload'))
+ const cases = [
+ {
+ status: 'completed',
+ decision: 'allow',
+ started: true,
+ revoked: false,
+ unknown: true,
+ expected: 'uncertain',
+ },
+ {
+ status: 'pending',
+ decision: 'skip',
+ started: false,
+ revoked: false,
+ unknown: false,
+ expected: 'cancelled',
+ },
+ {
+ status: 'cancelled',
+ decision: 'allow',
+ started: true,
+ revoked: false,
+ unknown: false,
+ expected: 'uncertain',
+ },
+ {
+ status: 'failed',
+ decision: 'allow',
+ started: true,
+ revoked: true,
+ unknown: true,
+ expected: 'uncertain',
+ },
+ {
+ status: 'failed',
+ decision: 'allow',
+ started: true,
+ revoked: false,
+ unknown: false,
+ expected: 'failed',
+ },
+ {
+ status: 'failed',
+ decision: 'allow',
+ started: false,
+ revoked: false,
+ unknown: false,
+ expected: 'failed',
+ },
+ {
+ status: 'pending',
+ decision: 'allow',
+ started: false,
+ revoked: false,
+ unknown: false,
+ expected: 'cancelled',
+ },
+ ] as const
+ for (const fixture of cases) {
+ const id = generateId()
+ await insertProposal(previousRunId, id, 'Historical outcome fixture')
+ await sql`update copilot_async_tool_calls set status = ${fixture.status}, permission_decision = ${fixture.decision}, execution_started_at = ${fixture.started ? new Date() : null}, execution_settled_at = ${fixture.started ? new Date() : null}, execution_revoked_at = ${fixture.revoked ? new Date() : null}, result = ${sql.json({ outcomeUnknown: fixture.unknown, notStarted: !fixture.started })} where tool_call_id = ${id}`
+ const stateResponse = await context.request.get(url, { params: { toolCallIds: id } })
+ assert.equal(stateResponse.status(), 200)
+ const state = listAssistantActionReceiptsContract.response.schema.parse(
+ await stateResponse.json()
+ ).receipts[0]
+ assert.equal(state?.state, fixture.expected)
+ assert.equal(state?.unsettled, false)
+ await sql`delete from copilot_async_tool_calls where tool_call_id = ${id}`
+ }
+ await sql`update copilot_async_tool_calls set result = ${sql.json({ output: { warning: '😀'.repeat(1400) } })} where tool_call_id = ${previousCallId}`
+ const unicodeResponse = await context.request.get(url, {
+ params: { toolCallIds: previousCallId },
+ })
+ assert.equal(unicodeResponse.status(), 200)
+ assert.equal(
+ listAssistantActionReceiptsContract.response.schema.parse(await unicodeResponse.json())
+ .receipts[0]?.warning,
+ '😀'.repeat(1024)
+ )
+ await sql`update copilot_async_tool_calls set result = ${sql.json({ output: { warning: receipt.warning } })} where tool_call_id = ${previousCallId}`
+ const scoped = await context.request.get(
+ `${baseUrl.origin}/api/organizations/${organizationId}/chats/${lagChatId}/actions/receipts`,
+ { params: { toolCallIds: previousCallId } }
+ )
+ assert.equal(scoped.status(), 200)
+ assert.deepEqual(
+ listAssistantActionReceiptsContract.response.schema.parse(await scoped.json()).receipts,
+ []
+ )
+ }
+ )
+ await check(
+ 'Display history carries the canonical turn identity and clears copied identities',
+ async () => {
+ for (const suffix of ['', '?window=latest']) {
+ const response = await context.request.get(
+ `${baseUrl.origin}/api/mothership/chats/${assistantId}${suffix}`
+ )
+ assert.equal(response.status(), 200)
+ const messages = toArray(toRecord(toRecord(await response.json()).chat).messages).map(
+ toRecord
+ )
+ assert.equal(
+ messages.find((message) => message.id === previousStreamId)?.streamId,
+ previousStreamId
+ )
+ }
+ const copiedId = generateId()
+ await sql`insert into copilot_messages (id, chat_id, message_id, role, content, seq) values (${copiedId}, ${lagChatId}, ${copiedId}, 'assistant', ${sql.json({ id: copiedId, role: 'assistant', content: 'Copied history', timestamp: new Date().toISOString(), streamId: previousStreamId })}, 2)`
+ try {
+ const response = await context.request.get(
+ `${baseUrl.origin}/api/mothership/chats/${lagChatId}`
+ )
+ assert.equal(response.status(), 200)
+ const messages = toArray(toRecord(toRecord(await response.json()).chat).messages).map(
+ toRecord
+ )
+ const copied = messages.find((message) => message.id === copiedId)
+ assert(copied)
+ assert.equal(copied.streamId, undefined)
+ } finally {
+ await sql`delete from copilot_messages where id = ${copiedId}`
+ }
+ }
+ )
await check('A native Assistant account exposes its own tool permissions', async () => {
await page.goto(`${baseUrl.origin}${root}/integrations`, {
waitUntil: 'domcontentloaded',
timeout: 180_000,
})
- await page.getByRole('button', { name: 'Salesforce integration actions', exact: true }).click()
- await expect(page.getByRole('menuitem', { name: 'Add account', exact: true })).toHaveCount(0)
- await expect(page.getByRole('menuitem', { name: 'Reconnect', exact: true })).toBeVisible()
- const permissions = page.locator(
- `a[href="${root}/integrations/accounts/${nativeCredentialId}"]`
- )
- await expect(permissions).toBeVisible({ timeout: 180_000 })
- await permissions.click()
+ await page.getByRole('link', { name: 'Open Salesforce integration', exact: true }).click()
+ await expect(page.getByRole('button', { name: 'Add account', exact: true })).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Reconnect', exact: true })).toBeVisible()
+ await capture('integration-account-actions')
+ await page.getByRole('button', { name: 'Disconnect', exact: true }).click()
+ await expect(page.getByRole('dialog', { name: 'Disconnect Salesforce' })).toBeVisible()
+ await capture('integration-disconnect-confirmation')
+ await page.getByRole('button', { name: 'Cancel', exact: true }).click()
const policy = page.getByRole('radiogroup', { name: 'Create Task in Salesforce permission' })
await expect(policy).toBeVisible({ timeout: 180_000 })
await expect(policy.getByRole('radio', { name: 'Ask', exact: true })).toBeChecked()
+ await capture('integration-detail-salesforce')
})
- await check('Connected app menus agree with the real Assistant tool catalog', async () => {
- await page.goto(`${baseUrl.origin}${root}/integrations`, { waitUntil: 'domcontentloaded' })
+ await check('Integration details agree with the real Assistant tool catalog', async () => {
for (const account of catalogAccounts) {
+ await page.goto(`${baseUrl.origin}${root}/integrations`, { waitUntil: 'domcontentloaded' })
const response = await context.request.get(
`${baseUrl.origin}/api/organizations/${organizationId}/connected-accounts/personal/${account.credentialId}/tool-permissions`
)
@@ -356,26 +579,196 @@ try {
const data = readAssistantToolPermissionsContract.response.schema.parse(await response.json())
assert.equal(data.account.credentialId, account.credentialId)
assert.equal(data.tools.length > 0, account.supported, account.name)
- const menu = page.getByRole('button', {
- name: `${account.name} integration actions`,
- exact: true,
- })
- await expect(menu).toHaveCount(1)
- await menu.click()
- const permissions = page.getByRole('menu').locator('a[href*="/integrations/accounts/"]')
- await expect(permissions).toHaveCount(account.supported ? 1 : 0)
+ await page
+ .getByRole('link', { name: `Open ${account.name} integration`, exact: true })
+ .click()
+ const permissions = page.getByRole('textbox', { name: 'Search tools', exact: true })
if (account.supported) {
- await expect(permissions).toHaveAttribute(
- 'href',
- `${root}/integrations/accounts/${account.credentialId}`
- )
+ await expect(permissions).toBeVisible()
+ await expect(page.getByRole('radiogroup').first()).toBeVisible()
} else {
await expect(permissions).toHaveCount(0)
}
- await page.keyboard.press('Escape')
+ await capture(`integration-detail-${account.name.toLowerCase().replaceAll(' ', '-')}`)
}
+ await page.goto(`${baseUrl.origin}${root}/integrations`)
+ await expect(
+ page.getByRole('link', { name: 'Open Salesforce integration', exact: true })
+ ).toBeVisible()
await capture('integrations-desktop')
})
+ await check(
+ 'Multiple accounts isolate permissions and disconnect the selected account only',
+ async () => {
+ await page.goto(`${baseUrl.origin}${root}/integrations`)
+ await page.getByRole('link', { name: 'Open Jira integration', exact: true }).click()
+ await expect(page).toHaveURL((url) => url.searchParams.get('integration') === 'source:jira')
+ const accountsUrl = page.url()
+ const firstAccount = page.getByRole('link', {
+ name: 'Open taylor@acme.example account',
+ exact: true,
+ })
+ const secondAccount = page.getByRole('link', {
+ name: 'Open launch-team@acme.example account',
+ exact: true,
+ })
+ await expect(firstAccount).toBeVisible()
+ await expect(secondAccount).toBeVisible()
+ await expect(page.getByRole('textbox', { name: 'Search tools', exact: true })).toHaveCount(0)
+ await capture('integration-multiple-accounts')
+ await expect(
+ page.getByRole('button', { name: 'Jira integration actions', exact: true })
+ ).toHaveCount(0)
+ await page.setViewportSize({ width: 390, height: 844 })
+ await capture('integration-multiple-accounts-mobile')
+ assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth))
+ await page.setViewportSize({ width: 1440, height: 1000 })
+ await secondAccount.click()
+ await expect(page).toHaveURL((url) => url.searchParams.get('account') === secondCredentialId)
+ const selectedUrl = page.url()
+ const policy = page.getByRole('radiogroup', { name: 'Jira Write permission' })
+ await expect(policy.getByRole('radio', { name: 'Ask', exact: true })).toBeChecked()
+ await capture('integration-selected-account')
+ await policy.getByRole('radio', { name: 'Block', exact: true }).click()
+ await expect(policy.getByRole('radio', { name: 'Block', exact: true })).toBeChecked()
+ await page.reload()
+ await expect(policy.getByRole('radio', { name: 'Block', exact: true })).toBeChecked()
+ for (const [id, expected] of [
+ [credentialId, 'ask'],
+ [secondCredentialId, 'block'],
+ ] as const) {
+ const response = await context.request.get(
+ `${baseUrl.origin}/api/organizations/${organizationId}/connected-accounts/personal/${id}/tool-permissions`
+ )
+ assert.equal(response.status(), 200)
+ const permissions = readAssistantToolPermissionsContract.response.schema.parse(
+ await response.json()
+ )
+ assert.equal(
+ permissions.tools.find((tool) => tool.toolId === 'jira_write')?.policy,
+ expected
+ )
+ }
+ await page.getByRole('link', { name: 'Jira', exact: true }).click()
+ await expect(firstAccount).toBeVisible()
+ await firstAccount.click()
+ await expect(policy.getByRole('radio', { name: 'Ask', exact: true })).toBeChecked()
+ await page.goto(selectedUrl)
+ await expect(page.getByRole('button', { name: 'Reconnect', exact: true })).toBeVisible()
+ await expect(policy.getByRole('radio', { name: 'Block', exact: true })).toBeChecked()
+ await capture('integration-selected-account-actions')
+ await page.getByRole('button', { name: 'Disconnect', exact: true }).click()
+ const confirmation = page.getByRole('dialog', { name: 'Disconnect Jira', exact: true })
+ await expect(confirmation).toContainText('launch-team@acme.example')
+ await expect(confirmation).not.toContainText('taylor@acme.example')
+ await capture('integration-selected-account-disconnect')
+ const disconnected = page.waitForResponse(
+ (response) =>
+ response.request().method() === 'DELETE' &&
+ new URL(response.url()).pathname ===
+ `/api/users/me/organization-accounts/${secondCredentialId}`
+ )
+ await confirmation.getByRole('button', { name: 'Disconnect', exact: true }).click()
+ assert.equal((await disconnected).status(), 200)
+ await expect(confirmation).toBeHidden()
+ await expect(
+ page.getByText('This account is no longer connected.', { exact: true })
+ ).toBeVisible()
+ await expect(page.getByRole('textbox', { name: 'Search tools', exact: true })).toHaveCount(0)
+ const states =
+ await sql`select id, managed_oauth_status from credential where id in (${credentialId}, ${secondCredentialId})`
+ assert.equal(
+ states.find((account) => account.id === credentialId)?.managed_oauth_status,
+ 'active'
+ )
+ assert.equal(
+ states.find((account) => account.id === secondCredentialId)?.managed_oauth_status,
+ 'revoked'
+ )
+ await page.goto(accountsUrl)
+ await expect(policy.getByRole('radio', { name: 'Ask', exact: true })).toBeChecked()
+ }
+ )
+ await check(
+ 'Enabled, disabled and paused empty integrations preserve MCP account isolation',
+ async () => {
+ const inventoryResponse = await context.request.get(
+ `${baseUrl.origin}/api/organizations/${organizationId}/connected-accounts`
+ )
+ assert.equal(inventoryResponse.status(), 200)
+ const inventory = getOrganizationAccountsContract.response.schema.parse(
+ await inventoryResponse.json()
+ )
+ assert(
+ inventory.viewerMcpAccounts?.some(
+ (account) => account.credentialId === firefliesCredentialId
+ )
+ )
+ await page.goto(`${baseUrl.origin}${root}/integrations`)
+ await page.getByRole('link', { name: 'Open Coda integration', exact: true }).click()
+ await expect(page).toHaveURL((url) => url.searchParams.get('integration') === 'source:coda')
+ const detailUrl = page.url()
+ const connect = page.getByRole('button', { name: 'Connect', exact: true })
+ await expect(connect).toBeEnabled()
+ await expect(page.getByText('No account connected', { exact: true })).toBeVisible()
+ await expect(page.getByText('meetings@acme.example', { exact: true })).toHaveCount(0)
+ await capture('integration-enabled-empty')
+ const forgedUrl = new URL(detailUrl)
+ forgedUrl.searchParams.set('account', firefliesCredentialId)
+ await page.goto(forgedUrl.toString())
+ await expect(
+ page.getByText('This account is no longer connected.', { exact: true })
+ ).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Disconnect', exact: true })).toHaveCount(0)
+ await expect(page.getByRole('textbox', { name: 'Search tools', exact: true })).toHaveCount(0)
+ await sql`update organization_search_integration set approved = false where organization_id = ${organizationId} and connector_type = 'coda'`
+ try {
+ await page.goto(`${baseUrl.origin}${root}/integrations`)
+ await expect(
+ page.getByRole('link', { name: 'Open Coda integration', exact: true })
+ ).toBeVisible()
+ await page.getByRole('link', { name: 'Open Coda integration', exact: true }).click()
+ await expect(page).toHaveURL((url) => url.searchParams.get('integration') === 'source:coda')
+ await expect(page.getByText('Disabled by your organization', { exact: true })).toBeVisible()
+ await expect(connect).toBeDisabled()
+ await capture('integration-disabled-empty')
+ } finally {
+ await sql`update organization_search_integration set approved = true where organization_id = ${organizationId} and connector_type = 'coda'`
+ }
+ await sql`update credential_group set status = 'disabled' where id = ${groupId}`
+ try {
+ await page.reload()
+ await expect(
+ page.getByText('Connections are paused by your organization', { exact: true })
+ ).toBeVisible()
+ await expect(connect).toBeDisabled()
+ await capture('integration-paused-empty')
+ } finally {
+ await sql`update credential_group set status = 'active' where id = ${groupId}`
+ }
+ await sql`update credential set managed_oauth_status = 'revoked' where id = ${nativeCredentialId}`
+ await sql`update credential_group set options = (select jsonb_agg(case when value->>'id' = 'salesforce' then value || '{"status":"disabled"}'::jsonb else value end) from jsonb_array_elements(options)) where id = ${groupId}`
+ try {
+ await page.goto(`${baseUrl.origin}${root}/integrations`)
+ const salesforce = page.getByRole('link', {
+ name: 'Open Salesforce integration',
+ exact: true,
+ })
+ await expect(salesforce).toBeVisible()
+ await salesforce.click()
+ await expect(page).toHaveURL(
+ (url) => url.searchParams.get('integration') === 'app:salesforce'
+ )
+ await expect(page.getByText('No account connected', { exact: true })).toBeVisible()
+ await expect(page.getByText('Connections unavailable', { exact: true })).toBeVisible()
+ await expect(connect).toHaveCount(0)
+ await capture('integration-disabled-app-empty')
+ } finally {
+ await sql`update credential set managed_oauth_status = 'active' where id = ${nativeCredentialId}`
+ await sql`update credential_group set options = (select jsonb_agg(case when value->>'id' = 'salesforce' then value || '{"status":"active"}'::jsonb else value end) from jsonb_array_elements(options)) where id = ${groupId}`
+ }
+ }
+ )
await check('Personal permissions persist through the real settings route', async () => {
await page.goto(`${baseUrl.origin}${root}/integrations/accounts/${credentialId}`, {
waitUntil: 'domcontentloaded',
@@ -402,6 +795,15 @@ try {
'A live Assistant turn restores exact inline actions without duplicating older rows',
async () => {
await page.setViewportSize({ width: 1440, height: 1000 })
+ const reviewUrl = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${assistantId}/actions`
+ await page.route(reviewUrl, async (route) => {
+ const response = await route.fetch()
+ const review = listAssistantActionsContract.response.schema.parse(await response.json())
+ await route.fulfill({
+ response,
+ json: { ...review, actions: review.actions.map(({ sequence, ...action }) => action) },
+ })
+ })
const streamResponse = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === '/api/mothership/chat/stream' &&
@@ -413,15 +815,35 @@ try {
timeout: 180_000,
})
assert.equal((await streamResponse).status(), 200)
- await expect(reviewCards).toHaveCount(2, { timeout: 180_000 })
- await expect(page.getByText('Earlier Jira proposal', { exact: false })).toBeAttached()
+ const reviewResponse = await context.request.get(
+ `${baseUrl.origin}/api/organizations/${organizationId}/chats/${assistantId}/actions`
+ )
+ assert.equal(reviewResponse.status(), 200, await reviewResponse.text())
+ const restoredReview = listAssistantActionsContract.response.schema.parse(
+ await reviewResponse.json()
+ )
+ assert.equal(restoredReview.actions.length, 2)
+ await expect(reviewCards).toHaveCount(2)
+ await page.unroute(reviewUrl)
+ await expect(
+ page
+ .getByRole('region', { name: 'Create Jira issue receipt' })
+ .filter({ hasText: 'Approved historical title' })
+ ).toBeAttached()
await expect(page.getByRole('button', { name: 'Allow', exact: true })).toHaveCount(2)
+ await expect(reviewCards.getByRole('textbox')).toHaveCount(0)
+ await expect(reviewCards.getByText('Priority', { exact: true })).toHaveCount(0)
+ const previewBounds = await reviewCards.nth(0).boundingBox()
+ assert(
+ previewBounds && previewBounds.height < 310,
+ 'The default review should fit in a compact preview'
+ )
await expect(
- reviewCards.nth(0).getByRole('textbox', { name: 'Summary', exact: true })
- ).toHaveValue('Publish the launch checklist')
+ reviewCards.nth(0).getByText('Publish the launch checklist', { exact: true })
+ ).toBeVisible()
await expect(
- reviewCards.nth(1).getByRole('textbox', { name: 'Summary', exact: true })
- ).toHaveValue('Confirm the rollout owners')
+ reviewCards.nth(1).getByText('Confirm the rollout owners', { exact: true })
+ ).toBeVisible()
const prompt = await page.getByText(question, { exact: true }).elementHandle()
assert(prompt, 'The current user turn must be restored')
assert(
@@ -456,11 +878,15 @@ try {
await page.reload()
await expect(reviewCards).toHaveCount(2)
await expect(
- reviewCards.nth(0).getByRole('textbox', { name: 'Summary', exact: true })
- ).toHaveValue('Publish the launch checklist')
- await page.getByText(question, { exact: true }).scrollIntoViewIfNeeded()
+ reviewCards.nth(0).getByText('Publish the launch checklist', { exact: true })
+ ).toBeVisible()
+ await expect(
+ page
+ .getByRole('region', { name: 'Create Jira issue receipt' })
+ .filter({ hasText: 'Approved historical title' })
+ ).toBeAttached()
+ await reviewCards.last().scrollIntoViewIfNeeded()
await capture('assistant-actions-desktop')
- await reviewCards.nth(0).scrollIntoViewIfNeeded()
await capture('action-review-desktop')
await page.setViewportSize({ width: 390, height: 844 })
await reviewCards.nth(0).scrollIntoViewIfNeeded()
@@ -502,10 +928,16 @@ try {
await check(
'An edited approval and a cancellation affect only their saved proposals',
async () => {
+ await reviewCards.nth(0).getByRole('button', { name: 'Edit', exact: true }).click()
await reviewCards
.nth(0)
.getByRole('textbox', { name: 'Summary', exact: true })
.fill('Publish the reviewed launch checklist')
+ await capture('action-review-edit')
+ await reviewCards.nth(0).getByRole('button', { name: 'Done', exact: true }).click()
+ await expect(
+ reviewCards.nth(0).getByText('Publish the reviewed launch checklist', { exact: true })
+ ).toBeVisible()
const approval = page.waitForResponse(
(response) =>
response.request().method() === 'PATCH' &&
@@ -516,12 +948,17 @@ try {
const approvalResponse = await approval
assert.equal(approvalResponse.status(), 200, await approvalResponse.text())
await expect(reviewCards).toHaveCount(1)
- await expect(reviewCards.getByRole('textbox', { name: 'Summary', exact: true })).toHaveValue(
- 'Confirm the rollout owners'
- )
await expect(
- page.getByText('No longer waiting for review', { exact: false }).first()
+ reviewCards.getByText('Confirm the rollout owners', { exact: true })
).toBeVisible()
+ const approvedReceipt = page
+ .getByRole('region', { name: 'Create Jira issue receipt' })
+ .filter({ hasText: 'Publish the reviewed launch checklist' })
+ await expect(approvedReceipt.getByText('Approved', { exact: true })).toBeVisible()
+ await approvedReceipt.getByRole('button', { name: 'View Create Jira issue receipt' }).click()
+ await expect(approvedReceipt.getByRole('textbox')).toHaveCount(0)
+ await expect(approvedReceipt.getByText('acme.atlassian.net', { exact: true })).toBeVisible()
+ await approvedReceipt.getByRole('button', { name: 'Hide Create Jira issue receipt' }).click()
const cancellation = page.waitForResponse(
(response) =>
response.request().method() === 'PATCH' &&
@@ -532,6 +969,16 @@ try {
const cancellationResponse = await cancellation
assert.equal(cancellationResponse.status(), 200, await cancellationResponse.text())
await expect(reviewCards).toHaveCount(0)
+ const cancelledReceipt = page
+ .getByRole('region', { name: 'Create Jira issue receipt' })
+ .filter({ hasText: 'Confirm the rollout owners' })
+ await expect(cancelledReceipt.getByText('Cancelled', { exact: true })).toBeVisible()
+ await capture('action-receipts-mobile')
+ await page.setViewportSize({ width: 1440, height: 1000 })
+ await capture('action-receipts-desktop')
+ await page.reload()
+ await expect(approvedReceipt.getByText('Approved', { exact: true })).toBeVisible()
+ await expect(cancelledReceipt.getByText('Cancelled', { exact: true })).toBeVisible()
const rows =
await sql`select tool_call_id, permission_decision, args, approved_args, execution_started_at from copilot_async_tool_calls where run_id = ${runId} order by persist_seq`
assert.equal(rows[0]?.permission_decision, 'allow')
@@ -568,7 +1015,7 @@ try {
assert.deepEqual(initialData.actions, [])
await insertProposal(lagRunId, lagFirstCallId, 'Review the support handoff')
await insertProposal(lagRunId, lagSecondCallId, 'Prepare the launch announcement')
- await page.route(`${reviewUrl}*`, (route) =>
+ await page.route(reviewUrl, (route) =>
route.request().method() === 'GET' ? route.abort('failed') : route.continue()
)
try {
@@ -577,16 +1024,16 @@ try {
})
await expect(reviewCards).toHaveCount(0)
} finally {
- await page.unroute(`${reviewUrl}*`)
+ await page.unroute(reviewUrl)
}
await page.getByRole('button', { name: 'Retry', exact: true }).click()
await expect(reviewCards).toHaveCount(2)
await expect(
- reviewCards.nth(0).getByRole('textbox', { name: 'Summary', exact: true })
- ).toHaveValue('Review the support handoff')
+ reviewCards.nth(0).getByText('Review the support handoff', { exact: true })
+ ).toBeVisible()
await expect(
- reviewCards.nth(1).getByRole('textbox', { name: 'Summary', exact: true })
- ).toHaveValue('Prepare the launch announcement')
+ reviewCards.nth(1).getByText('Prepare the launch announcement', { exact: true })
+ ).toBeVisible()
await appendStreamEvents(lagChatId, lagStreamId, [
pendingToolEvent(lagFirstCallId, 'Review the support handoff'),
pendingToolEvent(lagSecondCallId, 'Prepare the launch announcement'),
@@ -636,8 +1083,8 @@ try {
])
await expect(reviewCards).toHaveCount(1)
await expect(
- reviewCards.getByRole('textbox', { name: 'Summary', exact: true })
- ).toHaveValue('A later proposal that was never approved')
+ reviewCards.getByText('A later proposal that was never approved', { exact: true })
+ ).toBeVisible()
const cancellation = page.waitForResponse(
(response) =>
response.request().method() === 'PATCH' &&
@@ -814,6 +1261,7 @@ try {
}
})
try {
+ await expect(reviewCards.getByRole('button', { name: 'Cancel', exact: true })).toBeEnabled()
await reviewCards.getByRole('button', { name: 'Cancel', exact: true }).focus()
await page.keyboard.press('Enter')
await expect.poll(() => decisionPersisted).toBe(true)
@@ -907,11 +1355,130 @@ try {
)
assert.equal(response.status(), 404)
})
+ await check(
+ 'A 64-action recovery preserves sequence, pagination and receipts with bounded requests',
+ async () => {
+ const batchIds = Array.from({ length: 64 }, () => generateId())
+ for (const [index, id] of batchIds.entries()) {
+ await insertProposal(bulkRunId, id, `Batch issue ${index + 1}`)
+ if (index % 2 === 0) {
+ await sql`update copilot_async_tool_calls set status = 'delivered', permission_decision = 'skip', permission_decided_at = now() where run_id = ${bulkRunId} and tool_call_id = ${id}`
+ }
+ }
+ await appendStreamEvents(bulkChatId, bulkStreamId, [
+ { type: 'session', payload: { kind: 'start' } },
+ { type: 'text', payload: { channel: 'assistant', text: 'Review the remaining actions.' } },
+ ])
+ const responsesBefore = httpResponses.length
+ await page.goto(`${baseUrl.origin}/o/${organizationId}/chat/${bulkChatId}?mode=assistant`)
+ const cards = page.locator(
+ 'section[aria-label="Review Create Jira issue"], section[aria-label="Create Jira issue receipt"]'
+ )
+ await expect(cards).toHaveCount(48)
+ await expect(cards.nth(0)).toContainText('Batch issue 1')
+ await expect(cards.nth(0)).toContainText('Cancelled')
+ await expect(cards.nth(1)).toContainText('Batch issue 2')
+ const initialReceiptRequests = httpResponses
+ .slice(responsesBefore)
+ .filter((response) => response.path.endsWith('/actions/receipts'))
+ assert(
+ initialReceiptRequests.length <= 6,
+ `Recovery made ${initialReceiptRequests.length} receipt requests`
+ )
+ await cards.nth(1).getByRole('button', { name: 'Allow', exact: true }).click()
+ await expect(cards.nth(1)).toContainText('Approved')
+ await expect(cards.nth(1)).toContainText('Batch issue 2')
+ await expect(cards.last()).toContainText('Batch issue 64')
+ await cards.last().getByRole('button', { name: 'Allow', exact: true }).click()
+ await expect(cards.last()).toContainText('Approved')
+ await expect(cards).toHaveCount(48)
+ await page.getByRole('button', { name: 'More action history', exact: true }).click()
+ await expect(cards).toHaveCount(64)
+ await page.reload()
+ await page.getByRole('button', { name: 'More action history', exact: true }).click()
+ await expect(cards).toHaveCount(64)
+ await expect(cards.nth(1)).toContainText('Approved')
+ await expect(cards.last()).toContainText('Approved')
+ await expect(cards.last()).toContainText('Batch issue 64')
+ await capture('approval-paginated-receipts')
+ const lastCallId = batchIds.at(-1)
+ const precedingCallId = batchIds.at(-3)
+ assert(lastCallId && precedingCallId)
+ await cards.nth(61).getByRole('button', { name: 'Allow', exact: true }).click()
+ await expect(cards.nth(61)).toContainText('Approved')
+ await sql`update copilot_async_tool_calls set status = 'completed', execution_started_at = now(), execution_settled_at = now(), completed_at = now(), result = ${sql.json({ success: true })} where run_id = ${bulkRunId} and tool_call_id = ${lastCallId}`
+ const receiptsUrl = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${bulkChatId}/actions/receipts`
+ for (const [index, fixture] of [
+ { id: lastCallId, summary: 'Batch issue 64', state: 'Completed' },
+ { id: precedingCallId, summary: 'Batch issue 62', state: 'Approved' },
+ ].entries()) {
+ const events = [pendingToolEvent(fixture.id, fixture.summary)]
+ if (fixture.state === 'Completed')
+ events.push({
+ type: 'tool',
+ payload: {
+ phase: 'result',
+ executor: 'sim',
+ mode: 'async',
+ toolCallId: fixture.id,
+ toolName: 'jira_write',
+ success: true,
+ status: 'success',
+ output: { success: true },
+ },
+ })
+ await appendStreamEvents(bulkChatId, bulkStreamId, events)
+ await page.route(`${receiptsUrl}*`, (route) => {
+ const ids = new URL(route.request().url()).searchParams.getAll('toolCallIds')
+ return ids.includes(fixture.id) ? route.abort('failed') : route.continue()
+ })
+ try {
+ await page.reload()
+ await expect(
+ page.getByText('Could not load action history', { exact: true })
+ ).toHaveCount(index + 1, { timeout: 15_000 })
+ await capture(`receipt-history-${fixture.state.toLowerCase()}-retry`)
+ } finally {
+ await page.unroute(`${receiptsUrl}*`)
+ }
+ await page.getByRole('button', { name: 'Retry', exact: true }).first().click()
+ await expect(
+ page
+ .getByRole('region', { name: 'Create Jira issue receipt' })
+ .filter({ hasText: fixture.summary })
+ ).toContainText(fixture.state)
+ }
+ }
+ )
+ await check(
+ 'Automatically allowed writes wait for execution, not a second approval',
+ async () => {
+ const autoCallId = generateId()
+ await insertProposal(runId, autoCallId, 'Automatically allowed issue')
+ await sql`update copilot_async_tool_calls set permission_requested_at = null where run_id = ${runId} and tool_call_id = ${autoCallId}`
+ try {
+ const response = await context.request.get(
+ `${baseUrl.origin}/api/organizations/${organizationId}/chats/${assistantId}/actions/receipts`,
+ { params: { toolCallIds: autoCallId } }
+ )
+ assert.equal(response.status(), 200, await response.text())
+ const result = listAssistantActionReceiptsContract.response.schema.parse(
+ await response.json()
+ )
+ assert.equal(result.receipts[0]?.state, 'approved')
+ } finally {
+ await sql`delete from copilot_async_tool_calls where run_id = ${runId} and tool_call_id = ${autoCallId}`
+ }
+ }
+ )
await check('Browser runtime and HTTP responses', async () => {
assert.deepEqual(browserErrors, [])
assert.deepEqual(serverErrors, [])
})
} catch (error) {
+ await writeFile(`${artifactDirectory}/failure.txt`, await page.locator('body').innerText()).catch(
+ () => undefined
+ )
logger.error('Assistant actions E2E failed', { error: getErrorMessage(error) })
await page.screenshot({ path: resolve(artifactDirectory, 'failure.png') }).catch(() => undefined)
process.exitCode = 1
@@ -942,7 +1509,7 @@ try {
await writeFile(
reportPath,
JSON.stringify(
- { checks, browserErrors, serverErrors, httpResponses, artifactDirectory },
+ { checks, browserErrors, browserMessages, serverErrors, httpResponses, artifactDirectory },
null,
2
)
diff --git a/apps/sim/scripts/test-organization-search-e2e.ts b/apps/sim/scripts/test-organization-search-e2e.ts
index 48b456d5e99..69c247772a6 100644
--- a/apps/sim/scripts/test-organization-search-e2e.ts
+++ b/apps/sim/scripts/test-organization-search-e2e.ts
@@ -4,6 +4,7 @@ import { dirname, resolve } from 'node:path'
import { type BrowserContext, chromium, expect } from '@playwright/test'
import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
import { createLogger } from '@sim/logger'
+import { createDeferred } from '@sim/testing/helpers/deferred'
import { toStringOrNull } from '@sim/utils/coerce'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId, generateShortId } from '@sim/utils/id'
@@ -11,6 +12,8 @@ import { toRecord } from '@sim/utils/object'
import { truncate } from '@sim/utils/string'
import { makeSignature } from 'better-auth/crypto'
import postgres from 'postgres'
+import { searchWorkspaceKnowledgeContract } from '@/lib/api/contracts/knowledge/search'
+import { type CreateEventInput, createEvent } from '@/lib/mothership/request/session/event'
/**
* Runs against a local app and disposable PostgreSQL with member Search and Access Control enabled.
@@ -362,29 +365,49 @@ try {
'Search and Assistant preserve independent drafts across tabs and reloads',
async () => {
await page.goto(`${baseUrl.origin}${root}/search`)
- await expect(page.getByRole('radio', { name: 'Search', exact: true })).toBeChecked()
+ await expect(
+ page
+ .getByRole('navigation', { name: 'Search views' })
+ .getByRole('button', { name: 'Search', exact: true })
+ ).toHaveAttribute('aria-current', 'page')
const searchInput = page.getByRole('textbox', { name: 'Search your sources' })
await searchInput.fill('Launch notes')
- await page.getByRole('radio', { name: 'Assistant', exact: true }).click()
+ await page
+ .getByRole('navigation', { name: 'Search views' })
+ .getByRole('button', { name: 'Assistant', exact: true })
+ .click()
const assistantInput = page.getByRole('textbox', { name: 'Ask Sim' })
await expect(assistantInput).toHaveValue('')
await assistantInput.fill('Summarize launch readiness')
- await page.getByRole('radio', { name: 'Search', exact: true }).click()
+ await page
+ .getByRole('navigation', { name: 'Search views' })
+ .getByRole('button', { name: 'Search', exact: true })
+ .click()
await expect(searchInput).toHaveValue('Launch notes')
- await page.getByRole('radio', { name: 'Assistant', exact: true }).click()
+ await page
+ .getByRole('navigation', { name: 'Search views' })
+ .getByRole('button', { name: 'Assistant', exact: true })
+ .click()
await expect(assistantInput).toHaveValue('Summarize launch readiness')
await expect(page).toHaveURL(
(url) => url.pathname === `${root}/search` && url.searchParams.get('mode') === 'assistant'
)
await page.reload()
- await expect(page.getByRole('radio', { name: 'Assistant', exact: true })).toBeChecked()
+ await expect(
+ page
+ .getByRole('navigation', { name: 'Search views' })
+ .getByRole('button', { name: 'Assistant', exact: true })
+ ).toHaveAttribute('aria-current', 'page')
await expect(assistantInput).toHaveValue('Summarize launch readiness')
}
)
await check(
'Traditional search submits the organization query and keeps results in Search',
async () => {
- await page.getByRole('radio', { name: 'Search', exact: true }).click()
+ await page
+ .getByRole('navigation', { name: 'Search views' })
+ .getByRole('button', { name: 'Search', exact: true })
+ .click()
const responsePromise = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === '/api/knowledge/search' &&
@@ -414,24 +437,299 @@ try {
'Summarize launch readiness'
)
})
- await check('Saved assistant history opens inside Search and starts a fresh search', async () => {
+ await check('Changing Search views preserves the conversation, filters, and draft', async () => {
+ const path = `${root}/search/chat/${assistantId}`
+ await page.goto(`${baseUrl.origin}${path}?q=Launch&source=slack&updated=7d`)
+ const input = page.getByRole('textbox', { name: 'Ask Sim' })
+ await input.fill('Which owners still need to reply?')
+ await page
+ .getByRole('navigation', { name: 'Search views' })
+ .getByRole('button', { name: 'Search', exact: true })
+ .click()
+ await expect(page).toHaveURL(
+ (url) => url.pathname === path && url.searchParams.get('mode') === 'search'
+ )
+ await page.reload()
+ await page
+ .getByRole('navigation', { name: 'Search views' })
+ .getByRole('button', { name: 'Assistant', exact: true })
+ .click()
+ await expect(page.getByText(answer, { exact: true })).toBeVisible()
+ await expect(input).toHaveValue('Which owners still need to reply?')
+ const restored = new URL(page.url())
+ assert.equal(restored.pathname, path)
+ assert.equal(restored.searchParams.get('q'), 'Launch')
+ assert.equal(restored.searchParams.get('source'), 'slack')
+ assert.equal(restored.searchParams.get('updated'), '7d')
+ })
+ await check('New chat stays in Assistant and starts with a fresh draft', async () => {
await page.goto(`${baseUrl.origin}${root}/search/chat/${assistantId}`)
- await expect(page.getByRole('radio', { name: 'Assistant', exact: true })).toBeChecked()
+ await expect(
+ page
+ .getByRole('navigation', { name: 'Search views' })
+ .getByRole('button', { name: 'Assistant', exact: true })
+ ).toHaveAttribute('aria-current', 'page')
await expect(page.getByText(answer, { exact: true })).toBeVisible()
await capture('assistant-conversation-desktop')
- await page.getByRole('button', { name: 'New search', exact: true }).click()
- await expect(page).toHaveURL(`${baseUrl.origin}${root}/search`)
- await expect(page.getByRole('radio', { name: 'Search', exact: true })).toBeChecked()
+ await page.getByRole('button', { name: 'New chat', exact: true }).click()
+ await expect(page).toHaveURL(`${baseUrl.origin}${root}/search?mode=assistant`)
+ await expect(page.getByRole('textbox', { name: 'Ask Sim' })).toHaveValue('')
+ await expect(
+ page
+ .getByRole('navigation', { name: 'Search views' })
+ .getByRole('button', { name: 'Assistant', exact: true })
+ ).toHaveAttribute('aria-current', 'page')
})
+ await check(
+ 'Admission, New chat and repeated summaries preserve mode, context and drafts',
+ async () => {
+ const releaseAdmission = createDeferred()
+ let heldAdmission: Promise | undefined
+ let includeAdmissionHeader = true
+ const posts: Record[] = []
+ const createdChatIds: string[] = []
+ const chatEndpoint = `${baseUrl.origin}/api/mothership/chat`
+ const searchEndpoint = `${baseUrl.origin}/api/knowledge/search`
+ const documentId = generateId()
+ const searchRequests: Record[] = []
+ const searchBody = searchWorkspaceKnowledgeContract.body
+ assert(searchBody)
+ await page.route(searchEndpoint, async (route) => {
+ const body = searchBody.parse(route.request().postDataJSON())
+ searchRequests.push(body)
+ await route.fulfill({
+ json: searchWorkspaceKnowledgeContract.response.schema.parse({
+ success: true,
+ data: {
+ query: body.query,
+ retrieval: { status: 'complete', timedOutLegs: [] },
+ results: [
+ {
+ documentId,
+ knowledgeBaseId: generateId(),
+ knowledgeBaseName: 'Team documents',
+ documentName: 'Launch checklist',
+ sourceUrl: 'https://example.invalid/launch',
+ connectorType: 'slack',
+ sourceModifiedAt: new Date().toISOString(),
+ author: null,
+ content: 'The launch checklist is ready for review.',
+ chunkIndex: 0,
+ similarity: 1,
+ },
+ ],
+ },
+ }),
+ })
+ })
+ await page.route(chatEndpoint, async (route) => {
+ if (route.request().method() !== 'POST') return route.continue()
+ const body = toRecord(route.request().postDataJSON())
+ const number = posts.push(body)
+ if (number === 1) await releaseAdmission.promise
+ await heldAdmission
+ const chatId = toStringOrNull(body.chatId) ?? generateId()
+ const streamId = toStringOrNull(body.userMessageId)
+ assert(streamId)
+ assert.equal(body.organizationId, organizationId)
+ assert.equal(body.mode, 'assistant')
+ createdChatIds.push(chatId)
+ const responseText = `Deterministic assistant response ${number}.`
+ const requestId = generateId()
+ await sql`insert into copilot_chats (id, user_id, organization_id, type, title, config)
+ values (${chatId}, ${userId}, ${organizationId}, 'mothership', 'Navigation test', ${sql.json({ conversationMode: 'assistant' })}) on conflict (id) do nothing`
+ for (const message of [
+ { id: streamId, role: 'user', content: String(body.message) },
+ { id: generateId(), role: 'assistant', content: responseText },
+ ]) {
+ await sql`insert into copilot_messages (id, chat_id, message_id, role, stream_id, content, seq)
+ values (${generateId()}, ${chatId}, ${message.id}, ${message.role}, ${streamId}, ${sql.json({ ...message, requestId, timestamp: new Date().toISOString() })}, (select coalesce(max(seq), 0) + 1 from copilot_messages where chat_id = ${chatId}))`
+ }
+ const base = { chatId, streamId, requestId }
+ const events: CreateEventInput[] = [
+ { ...base, cursor: '1', seq: 1, type: 'session', payload: { kind: 'chat', chatId } },
+ {
+ ...base,
+ cursor: '2',
+ seq: 2,
+ type: 'text',
+ payload: { channel: 'assistant', text: responseText, textOffset: 0 },
+ },
+ {
+ ...base,
+ cursor: '3',
+ seq: 3,
+ type: 'complete',
+ payload: { status: 'complete', textLength: responseText.length },
+ },
+ ]
+ await route.fulfill({
+ status: 200,
+ headers: {
+ 'content-type': 'text/event-stream',
+ ...(includeAdmissionHeader ? { 'x-mothership-chat-id': chatId } : {}),
+ },
+ body: `${events.map((event) => `data: ${JSON.stringify(createEvent(event))}\n\n`).join('')}data: [DONE]\n\n`,
+ })
+ })
+ try {
+ await page.goto(`${baseUrl.origin}${root}/search?q=Launch&source=slack&updated=7d`)
+ const navigation = page.getByRole('navigation', { name: 'Search views' })
+ await navigation.getByRole('button', { name: 'Assistant', exact: true }).click()
+ const composer = page.getByRole('textbox', { name: 'Ask Sim' })
+ await composer.fill('A new conversation before admission')
+ await composer.press('Enter')
+ await expect.poll(() => posts.length).toBe(1)
+ await page.goBack()
+ await expect(
+ navigation.getByRole('button', { name: 'Search', exact: true })
+ ).toHaveAttribute('aria-current', 'page')
+ releaseAdmission.resolve()
+ await expect(page).toHaveURL(
+ (url) =>
+ url.pathname === `${root}/search/chat/${createdChatIds[0]}` &&
+ url.searchParams.get('mode') === 'search'
+ )
+ assert.equal(new URL(page.url()).searchParams.get('q'), 'Launch')
+ assert.equal(new URL(page.url()).searchParams.get('source'), 'slack')
+ assert.equal(new URL(page.url()).searchParams.get('updated'), '7d')
+ await expect(
+ page.getByText('Deterministic assistant response 1.', { exact: true })
+ ).not.toBeVisible()
+ await navigation.getByRole('button', { name: 'Assistant', exact: true }).click()
+ await expect(
+ page.getByText('Deterministic assistant response 1.', { exact: true })
+ ).toBeVisible()
+ await composer.fill('An unsent follow-up')
+ await page.getByRole('button', { name: 'New chat', exact: true }).click()
+ await expect(page).toHaveURL(`${baseUrl.origin}${root}/search?mode=assistant`)
+ await expect(composer).toHaveValue('')
+ await composer.fill('A distinct new conversation')
+ await composer.press('Enter')
+ await expect(
+ page.getByText('Deterministic assistant response 2.', { exact: true })
+ ).toBeVisible()
+ assert.equal(posts.length, 2)
+ assert.equal(posts[1]?.createNewChat, true)
+ assert.equal(posts[1]?.chatId, undefined)
+ assert.notEqual(createdChatIds[0], createdChatIds[1])
+ await expect(page).toHaveURL(
+ (url) => url.pathname === `${root}/search/chat/${createdChatIds[1]}`
+ )
+ await page.reload()
+ await expect(
+ page.getByText('Deterministic assistant response 2.', { exact: true })
+ ).toBeVisible()
+ await expect(
+ page.getByText('Deterministic assistant response 1.', { exact: true })
+ ).toHaveCount(0)
+ await capture('assistant-new-chat-after-admission')
+ const summaryStartedAt = Date.now()
+ await page.goto(`${baseUrl.origin}${root}/search?q=Launch&source=slack&updated=7d`)
+ await page
+ .getByRole('button', { name: 'Actions for Launch checklist', exact: true })
+ .click()
+ await page.getByRole('menuitem', { name: 'Summarize', exact: true }).click()
+ await expect(
+ page.getByText('Deterministic assistant response 3.', { exact: true })
+ ).toBeVisible()
+ const summaryChatId = createdChatIds[2]
+ assert.equal(posts[2]?.createNewChat, true)
+ assert.equal(posts[2]?.assistantSearchLevel, 'fast')
+ const { modifiedAfter: firstCutoff, ...firstSummaryFilters } = toRecord(
+ posts[2]?.assistantSearch
+ )
+ const { modifiedAfter: searchCutoff, ...searchFilters } = toRecord(
+ searchRequests.at(-1)?.filters
+ )
+ assert.equal(searchFilters.source, 'slack')
+ assert.deepEqual(firstSummaryFilters, { ...searchFilters, documentIds: [documentId] })
+ assert.equal(typeof firstCutoff, 'string')
+ assert(Date.parse(String(firstCutoff)) >= summaryStartedAt - 7 * 24 * 60 * 60 * 1000)
+ assert(Date.parse(String(firstCutoff)) <= Date.now() - 7 * 24 * 60 * 60 * 1000)
+ await composer.fill('Keep this unsent draft')
+ await navigation.getByRole('button', { name: 'Search', exact: true }).click()
+ await page
+ .getByRole('button', { name: 'Actions for Launch checklist', exact: true })
+ .click()
+ await page.getByRole('menuitem', { name: 'Summarize', exact: true }).click()
+ await expect(
+ page.getByText('Deterministic assistant response 4.', { exact: true })
+ ).toBeVisible()
+ assert.equal(posts.length, 4)
+ assert.equal(posts[3]?.chatId, summaryChatId)
+ assert.equal(posts[3]?.createNewChat, false)
+ const { modifiedAfter: repeatedCutoff, ...repeatedSummaryFilters } = toRecord(
+ posts[3]?.assistantSearch
+ )
+ assert.deepEqual(repeatedSummaryFilters, firstSummaryFilters)
+ assert.equal(typeof repeatedCutoff, 'string')
+ assert(Date.parse(String(repeatedCutoff)) >= Date.parse(String(firstCutoff)))
+ assert(Date.parse(String(repeatedCutoff)) <= Date.now() - 7 * 24 * 60 * 60 * 1000)
+ await expect(composer).toHaveValue('Keep this unsent draft')
+ await capture('assistant-repeat-summary-preserves-draft')
+ for (const selectedMode of ['search', 'assistant'] as const) {
+ includeAdmissionHeader = selectedMode === 'search'
+ await page.goto(`${baseUrl.origin}${root}/search?mode=assistant&q=Launch&source=slack`)
+ await composer.fill(`Preserve the pending ${selectedMode} selection`)
+ const admission = createDeferred()
+ heldAdmission = admission.promise
+ const requestNumber = posts.length + 1
+ try {
+ await composer.press('Enter')
+ await expect.poll(() => posts.length).toBe(requestNumber)
+ if (selectedMode === 'assistant') {
+ await navigation.getByRole('button', { name: 'Search', exact: true }).click()
+ await expect(page).toHaveURL((url) => url.searchParams.get('mode') === 'search')
+ }
+ await page.clock.pauseAt(new Date(Date.now() + 100))
+ const selected = navigation.getByRole('button', {
+ name: selectedMode === 'assistant' ? 'Assistant' : 'Search',
+ exact: true,
+ })
+ await selected.click({ force: true })
+ await expect(selected).toHaveAttribute('aria-current', 'page')
+ assert.notEqual(new URL(page.url()).searchParams.get('mode'), selectedMode)
+ admission.resolve()
+ await expect(page).toHaveURL(
+ (url) =>
+ url.pathname === `${root}/search/chat/${createdChatIds.at(-1)}` &&
+ url.searchParams.get('mode') === selectedMode
+ )
+ assert.equal(new URL(page.url()).searchParams.get('source'), 'slack')
+ } finally {
+ admission.resolve()
+ heldAdmission = undefined
+ await page.clock.resume()
+ }
+ }
+ } finally {
+ releaseAdmission.resolve()
+ await page.unroute(chatEndpoint)
+ await page.unroute(searchEndpoint)
+ }
+ }
+ )
await check('Existing assistant bookmarks redirect into Search', async () => {
await page.goto(`${baseUrl.origin}${root}/chat/${assistantId}`)
await expect(page).toHaveURL(`${baseUrl.origin}${root}/search/chat/${assistantId}`)
await expect(page.getByText(answer, { exact: true })).toBeVisible()
})
+ await check('New search clears its draft while Back restores the prior query draft', async () => {
+ await page.goto(`${baseUrl.origin}${root}/search?q=Launch`)
+ const input = page.getByRole('textbox', { name: 'Search your sources' })
+ await input.fill('Edited launch query')
+ await page.getByRole('button', { name: 'New search', exact: true }).click()
+ await expect(page).toHaveURL(`${baseUrl.origin}${root}/search`)
+ await expect(input).toHaveValue('')
+ await page.goBack()
+ await expect(input).toHaveValue('Edited launch query')
+ assert.equal(new URL(page.url()).searchParams.get('q'), 'Launch')
+ })
await check('Search URLs cannot change an agent conversation into an assistant', async () => {
await page.goto(`${baseUrl.origin}${root}/search/chat/${agentId}`)
await expect(page).toHaveURL(`${baseUrl.origin}${root}/chat/${agentId}`)
- await expect(page.getByRole('radiogroup', { name: 'Search mode' })).toHaveCount(0)
+ await expect(page.getByRole('navigation', { name: 'Search views' })).toHaveCount(0)
})
await check('Assistant history rejects a chat from another organization', async () => {
await page.goto(`${baseUrl.origin}${root}/search/chat/${deniedChatId}`)
@@ -447,8 +745,10 @@ try {
for (const mode of ['search', 'assistant'] as const) {
await page.goto(`${baseUrl.origin}${root}/search?mode=${mode}`)
await expect(
- page.getByRole('radio', { name: mode === 'search' ? 'Search' : 'Assistant', exact: true })
- ).toBeChecked()
+ page
+ .getByRole('navigation', { name: 'Search views' })
+ .getByRole('button', { name: mode === 'search' ? 'Search' : 'Assistant', exact: true })
+ ).toHaveAttribute('aria-current', 'page')
await expect(
page.getByRole('textbox', { name: mode === 'search' ? 'Search your sources' : 'Ask Sim' })
).toBeVisible()
diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json
index fe4c7d33dea..df78587a75a 100644
--- a/scripts/check-explicit-any.baseline.json
+++ b/scripts/check-explicit-any.baseline.json
@@ -1190,7 +1190,6 @@
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx": 1,
"apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx": 1,
"apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx": 2,
- "apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts": 1,
"apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts": 2,
"apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-context-menu/chunk-context-menu.tsx": 1,
"apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1,