Skip to content

Commit 8a3fb00

Browse files
committed
test(mothership): drive stale-tab and owner-hint failures through the real boundary
1 parent cdc78c6 commit 8a3fb00

6 files changed

Lines changed: 445 additions & 330 deletions

File tree

‎apps/sim/lib/mothership/application/file-owner-context.test.ts‎

Lines changed: 0 additions & 91 deletions
This file was deleted.

‎apps/sim/lib/mothership/chat/post.test.ts‎

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
billingAttributionMockFns,
1919
} from '@sim/testing/mocks/billing-attribution.mock'
2020
import { knowledgeAvailabilityMock } from '@sim/testing/mocks/knowledge-availability.mock'
21-
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
2221
import {
2322
mothershipChatLifecycleMock,
2423
mothershipChatLifecycleMockFns,
@@ -1170,53 +1169,6 @@ describe('handleUnifiedChatPost', () => {
11701169
])
11711170
})
11721171

1173-
it('skips stale tabs at a log level matching why they no longer resolve', async () => {
1174-
const chatLogger = getMockLogger('UnifiedChatAPI')
1175-
const boom = new Error('boom')
1176-
resolveActiveResourceContext.mockImplementation(async (_type: string, id: string) => {
1177-
if (id === 'deleted') throw new OrchestrationError('not_found', 'Table not found')
1178-
if (id === 'revoked') throw new OrchestrationError('forbidden', 'Access denied')
1179-
if (id === 'broken') throw boom
1180-
return { type: 'active_resource', content: 'live table' }
1181-
})
1182-
const response = await handleUnifiedChatPost(
1183-
new NextRequest('http://localhost/api/copilot/chat', {
1184-
method: 'POST',
1185-
body: JSON.stringify({
1186-
message: 'Use my tabs',
1187-
workspaceId: 'ws-1',
1188-
createNewChat: true,
1189-
resourceAttachments: [
1190-
{ type: 'table', id: 'deleted', title: 'Gone' },
1191-
{ type: 'workflow', id: 'revoked', title: 'Revoked' },
1192-
{ type: 'knowledgebase', id: 'broken', title: 'Broken' },
1193-
{ type: 'table', id: 'live', title: 'Live' },
1194-
],
1195-
}),
1196-
})
1197-
)
1198-
1199-
expect(response.status).toBe(200)
1200-
expect(chatLogger.info).toHaveBeenCalledWith(
1201-
expect.stringContaining('Skipped resource attachment that no longer exists'),
1202-
{ resourceType: 'table' }
1203-
)
1204-
expect(chatLogger.warn).toHaveBeenCalledWith(
1205-
expect.stringContaining('Skipped resource attachment the user can no longer access'),
1206-
{ resourceType: 'workflow' }
1207-
)
1208-
expect(chatLogger.error).toHaveBeenCalledTimes(1)
1209-
expect(chatLogger.error).toHaveBeenCalledWith(
1210-
expect.stringContaining('Failed to resolve resource attachment'),
1211-
boom
1212-
)
1213-
expect(buildCopilotRequestPayload).toHaveBeenCalledWith(
1214-
expect.objectContaining({
1215-
contexts: [{ type: 'active_resource', content: 'live table', tag: '@open_tab' }],
1216-
})
1217-
)
1218-
})
1219-
12201172
it('validates selection snapshots and omits unsafe browser source URLs', async () => {
12211173
const response = await handleUnifiedChatPost(
12221174
new NextRequest('http://localhost/api/copilot/chat', {

‎apps/sim/lib/mothership/chat/post.ts‎

Lines changed: 16 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {
2020
resolveBillingAttribution,
2121
resolveOrganizationBillingAttribution,
2222
} from '@/lib/billing/core/attribution'
23-
import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
2423
import { type AtomicClaimResult, chatSendIdempotency } from '@/lib/core/idempotency'
2524
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
2625
import { listPersonalCredentials } from '@/lib/credentials/application/personal-credentials'
@@ -30,7 +29,6 @@ import {
3029
} from '@/lib/knowledge/access/availability'
3130
import { loadCopilotSearchIntegrations } from '@/lib/mothership/application/load-search-integrations'
3231
import { chatOperations } from '@/lib/mothership/application/operations'
33-
import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target'
3432
import {
3533
authoringInvocation,
3634
createCopilotResourceAdmission,
@@ -51,18 +49,15 @@ import {
5149
DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH,
5250
DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH,
5351
} from '@/lib/mothership/chat/desktop-capabilities'
54-
import { resolveFileAttachment } from '@/lib/mothership/chat/file-attachment'
5552
import {
5653
type ChatLoadResult,
5754
loadChatMcpServerIds,
5855
resolveOrCreateChat,
5956
} from '@/lib/mothership/chat/lifecycle'
6057
import { authorizeOrganizationChat } from '@/lib/mothership/chat/organization-chats'
6158
import { buildCopilotRequestPayload } from '@/lib/mothership/chat/payload'
62-
import {
63-
processContextsServer,
64-
resolveActiveResourceContext,
65-
} from '@/lib/mothership/chat/process-contents'
59+
import { processContextsServer } from '@/lib/mothership/chat/process-contents'
60+
import { resolveResourceAttachmentContexts } from '@/lib/mothership/chat/resource-attachments'
6661
import {
6762
MAX_FILE_SELECTION_TEXT_LENGTH,
6863
MAX_TABLE_SELECTION_COLUMNS,
@@ -100,13 +95,7 @@ import {
10095
} from '@/lib/mothership/request/session'
10196
import { getLocalChatStreamLease } from '@/lib/mothership/request/session/abort'
10297
import type { ExecutionContext } from '@/lib/mothership/request/types'
103-
import { persistChatResources } from '@/lib/mothership/resources/persistence'
104-
import { searchResourceMatchesOwner } from '@/lib/mothership/resources/search'
105-
import {
106-
hasAddressableId,
107-
isEphemeralResource,
108-
sanitizeChatResources,
109-
} from '@/lib/mothership/resources/types'
98+
import { hasAddressableId } from '@/lib/mothership/resources/types'
11099
import { prepareExecutionContext } from '@/lib/mothership/tools/handlers/context'
111100
import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions'
112101
import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response'
@@ -135,38 +124,6 @@ const FileAttachmentSchema = z.object({
135124
path: z.string().optional(),
136125
})
137126

138-
const GENERIC_RESOURCE_TITLE: Record<z.infer<typeof ResourceAttachmentSchema>['type'], string> = {
139-
search: 'Search results',
140-
sources: 'Sources',
141-
workflow: 'Workflow',
142-
table: 'Table',
143-
integration: 'Integration',
144-
file: 'File',
145-
dashboard: 'Dashboard',
146-
test: 'Test',
147-
knowledgebase: 'Knowledge Base',
148-
folder: 'Folder',
149-
filefolder: 'File Folder',
150-
task: 'Task',
151-
log: 'Log',
152-
generic: 'Resource',
153-
browser: 'Browser',
154-
terminal: 'Terminal',
155-
}
156-
157-
/**
158-
* Synthetic client-side panels are context-only: never persisted to the chat.
159-
* Browser tabs are among them — the desktop app restores its own pages — so
160-
* their page title and URL remain request context only.
161-
*/
162-
function isPersistableAttachment(resource: z.infer<typeof ResourceAttachmentSchema>): boolean {
163-
return !isEphemeralResource({
164-
type: resource.type,
165-
id: resource.id,
166-
title: resource.title ?? '',
167-
})
168-
}
169-
170127
/**
171128
* Drops open tabs the client cannot address, so one unusable tab does not fail
172129
* the whole message — clients on a stale bundle still send them. A non-string
@@ -622,133 +579,19 @@ async function resolveAgentContexts(params: {
622579
}
623580
}
624581

625-
const authorizedResources: z.infer<typeof mothershipResourceSchema>[] = []
626-
if (
627-
Array.isArray(resourceAttachments) &&
628-
resourceAttachments.length > 0 &&
629-
(workspaceId || organizationId)
630-
) {
631-
const results = await Promise.allSettled(
632-
resourceAttachments.map(async (resource) => {
633-
if (resource.type === 'sources') return null
634-
// The live browser panel resolves from the attachment itself: its
635-
// page state is client-held (the desktop app's embedded browser),
636-
// not a workspace entity the server could look up.
637-
if (resource.type === 'browser') {
638-
if (!resource.url) return null
639-
const title = resource.title?.trim()
640-
return {
641-
type: 'active_resource',
642-
tag: resource.active ? '@active_tab' : '@open_tab',
643-
content: `The user's ${
644-
resource.active ? 'currently visible browser tab' : 'other open browser tab'
645-
} is open on: ${
646-
title ? `"${title}" — ` : ''
647-
}${resource.url}. ${browserAvailable ? 'Browser tools are available for inspecting and interacting with this tab.' : 'This attachment supplies only the title and URL; browser tools are unavailable.'}`,
648-
}
649-
}
650-
if (resource.type === 'search') {
651-
if (
652-
!resource.search ||
653-
!searchResourceMatchesOwner(resource.search, { organizationId, workspaceId })
654-
)
655-
return null
656-
if (persistResources)
657-
authorizedResources.push(
658-
mothershipResourceSchema.parse({ ...resource, title: 'Search results' })
659-
)
660-
return {
661-
type: 'active_resource',
662-
tag: resource.active ? '@active_tab' : '@open_tab',
663-
content: `The user's Search results tab has this retrieval address: ${JSON.stringify(resource.search)}. This is query context, not retrieved evidence; use the search tools for current authorized results.`,
664-
}
665-
}
666-
if (resource.type === 'file') {
667-
const { context, tab } = await resolveFileAttachment(resource, {
668-
userId,
669-
workspaceId,
670-
organizationId,
671-
chatId,
672-
requestMode,
673-
})
674-
if (persistResources && tab) authorizedResources.push(tab)
675-
return context
676-
}
677-
const target =
678-
organizationId || resource.workspaceId
679-
? await resolveInvocationWorkspace(
680-
{ userId, workspaceId, organizationId, chatId },
681-
resource.workspaceId
682-
)
683-
: workspaceId
684-
? { workspaceId }
685-
: null
686-
if (!target) return null
687-
if (
688-
resource.owner &&
689-
(resource.owner.entityType !== 'workspace' ||
690-
resource.owner.entityId !== target.workspaceId)
691-
)
692-
throw new Error('File owner does not match the selected workspace')
693-
const ctx = await withWorkspaceInvocationScope(
694-
{ workspaceId: target.workspaceId, organizationId },
695-
() =>
696-
resolveActiveResourceContext(
697-
resource.type,
698-
resource.id,
699-
target.workspaceId,
700-
userId,
701-
chatId,
702-
resource.viewId,
703-
resource.currentView
704-
)
705-
)
706-
if (!ctx) return null
707-
if (persistResources && isPersistableAttachment(resource))
708-
authorizedResources.push(
709-
mothershipResourceSchema.parse({
710-
...resource,
711-
title: resource.title ?? GENERIC_RESOURCE_TITLE[resource.type],
712-
...(organizationId
713-
? { workspaceId: target.workspaceId }
714-
: { workspaceId: undefined, workspaceName: undefined }),
715-
})
716-
)
717-
return {
718-
...ctx,
719-
...(organizationId
720-
? { content: `Workspace ${target.workspaceId}:\n${ctx.content}` }
721-
: {}),
722-
tag: resource.active ? '@active_tab' : '@open_tab',
723-
}
724-
})
725-
)
726-
727-
results.forEach((result, index) => {
728-
if (result.status === 'fulfilled') {
729-
if (result.value) agentContexts.push(result.value)
730-
return
731-
}
732-
// A tab can outlive its resource or the viewer's access to it; the turn
733-
// goes without that context.
734-
const resourceType = resourceAttachments[index]?.type
735-
const code = asOrchestrationError(result.reason)?.code
736-
if (code === 'not_found') {
737-
logger.info(`[${requestId}] Skipped resource attachment that no longer exists`, {
738-
resourceType,
739-
})
740-
} else if (code === 'forbidden') {
741-
logger.warn(`[${requestId}] Skipped resource attachment the user can no longer access`, {
742-
resourceType,
743-
})
744-
} else {
745-
logger.error(`[${requestId}] Failed to resolve resource attachment`, result.reason)
746-
}
747-
})
748-
}
749-
750-
if (chatId && authorizedResources.length)
751-
await persistChatResources(chatId, sanitizeChatResources(authorizedResources))
582+
agentContexts.push(
583+
...(await resolveResourceAttachmentContexts({
584+
resourceAttachments,
585+
userId,
586+
workspaceId,
587+
organizationId,
588+
chatId,
589+
requestMode,
590+
persistResources,
591+
browserAvailable,
592+
requestId,
593+
}))
594+
)
752595
return agentContexts
753596
}
754597

0 commit comments

Comments
 (0)