Skip to content

Commit cb5027a

Browse files
authored
fix(chat): validate restored contexts against send schema (#9294)
* fix(chat): validate restored contexts against send schema * fix(chat): hold queued sends with unavailable context * fix(chat): show queue warnings and bound recovered contexts
1 parent 044805a commit cb5027a

16 files changed

Lines changed: 556 additions & 276 deletions

File tree

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx‎

Lines changed: 72 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -375,64 +375,81 @@ async function paste(files: File[]) {
375375
return event
376376
}
377377

378-
it('keeps restored queued skills scoped when replacing a draft', async () => {
379-
let restore: () => void = () => {}
380-
function Harness() {
381-
const [value, setValue] = useState('Original draft')
382-
const [contexts, setContexts] = useState<ComponentProps<typeof Composer>['restoredContexts']>()
383-
restore = () => {
384-
setValue('/review fix this')
385-
setContexts([
386-
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
387-
])
378+
it.each([false, true])(
379+
'keeps restored queued skills scoped and warns about lost context (%s)',
380+
async (invalidContext) => {
381+
let restore: () => void = () => {}
382+
function Harness() {
383+
const [value, setValue] = useState('Original draft')
384+
const [contexts, setContexts] =
385+
useState<ComponentProps<typeof Composer>['restoredContexts']>()
386+
restore = () => {
387+
setValue('/review fix this @Browser')
388+
setContexts([
389+
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
390+
...(invalidContext
391+
? [
392+
{
393+
kind: 'browser_tab' as const,
394+
tabId: 'tab-a',
395+
label: 'Browser',
396+
selection: { text: 'Selected passage', title: 'x'.repeat(513) },
397+
},
398+
]
399+
: []),
400+
])
401+
}
402+
const files = useFileAttachments({
403+
userId: 'user-a',
404+
organizationId: 'organization-a',
405+
requestMode: 'agent',
406+
})
407+
return (
408+
<Composer
409+
requestMode='agent'
410+
value={value}
411+
onChange={setValue}
412+
restoredContexts={contexts}
413+
files={files}
414+
isInitialView={false}
415+
isSending={false}
416+
onStop={() => {}}
417+
onSubmit={mocks.submit}
418+
/>
419+
)
388420
}
389-
const files = useFileAttachments({
390-
userId: 'user-a',
391-
organizationId: 'organization-a',
392-
requestMode: 'agent',
393-
})
394-
return (
395-
<Composer
396-
requestMode='agent'
397-
value={value}
398-
onChange={setValue}
399-
restoredContexts={contexts}
400-
files={files}
401-
isInitialView={false}
402-
isSending={false}
403-
onStop={() => {}}
404-
onSubmit={mocks.submit}
405-
/>
421+
await act(async () =>
422+
root.render(
423+
<QueryClientProvider client={queryClient}>
424+
<FeatureFlagsProvider
425+
flags={{
426+
dashboards: false,
427+
'workflow-tests': false,
428+
changelog: false,
429+
'mothership-model-selector': mocks.advanced,
430+
'mothership-plan-mode': mocks.plan,
431+
}}
432+
>
433+
<Harness />
434+
<ToastProvider />
435+
</FeatureFlagsProvider>
436+
</QueryClientProvider>
437+
)
406438
)
407-
}
408-
await act(async () =>
409-
root.render(
410-
<QueryClientProvider client={queryClient}>
411-
<FeatureFlagsProvider
412-
flags={{
413-
dashboards: false,
414-
'workflow-tests': false,
415-
changelog: false,
416-
'mothership-model-selector': mocks.advanced,
417-
'mothership-plan-mode': mocks.plan,
418-
}}
419-
>
420-
<Harness />
421-
</FeatureFlagsProvider>
422-
</QueryClientProvider>
439+
await act(async () => restore())
440+
expect(
441+
container.querySelector<HTMLInputElement | HTMLTextAreaElement>('[aria-label="Ask Sim"]')!
442+
.value
443+
).toBe('\u2003review fix this @Browser')
444+
if (invalidContext) expect(document.body.textContent).toContain('Reattach it before sending.')
445+
await act(async () =>
446+
container.querySelector<HTMLButtonElement>('button[aria-label="Send"]')!.click()
423447
)
424-
)
425-
await act(async () => restore())
426-
expect(
427-
container.querySelector<HTMLInputElement | HTMLTextAreaElement>('[aria-label="Ask Sim"]')!.value
428-
).toBe('\u2003review fix this')
429-
await act(async () =>
430-
container.querySelector<HTMLButtonElement>('button[aria-label="Send"]')!.click()
431-
)
432-
expect(mocks.submit).toHaveBeenLastCalledWith('/review fix this', [
433-
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
434-
])
435-
})
448+
expect(mocks.submit).toHaveBeenLastCalledWith('/review fix this @Browser', [
449+
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
450+
])
451+
}
452+
)
436453

437454
it('shows global built-ins once with no workspace label and submits no invented workspace', async () => {
438455
mocks.workspaces = []

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react'
44
import { Chip, ComposerActionButton, cn, Tooltip, toast } from '@sim/emcn'
55
import { ArrowUp, Paperclip, Plus, Search, Slash, StopFilled } from '@sim/emcn/icons'
66
import { useQueries } from '@tanstack/react-query'
7+
import { UNAVAILABLE_CONTEXT_NOTICE } from '@/lib/mothership/chat/context'
78
import {
89
ASSISTANT_IMAGE_ACCEPT_ATTRIBUTE,
910
isAssistantImageType,
@@ -153,7 +154,11 @@ export function Composer({
153154
useEffect(() => {
154155
if (!restoredContexts) return
155156
history.reset()
156-
editorRef.current.restorePrompt(editorRef.current.getPlainValue(), restoredContexts)
157+
const { hasUnavailableContexts } = editorRef.current.restorePrompt(
158+
editorRef.current.getPlainValue(),
159+
restoredContexts
160+
)
161+
if (hasUnavailableContexts) toast.info(UNAVAILABLE_CONTEXT_NOTICE)
157162
}, [restoredContexts, history.reset])
158163
useChatInputFocus({ textareaRef })
159164
const voice = useVoiceInput({

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) }))
1111
vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) }))
1212
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
1313

14+
import { toDisplayMessage } from '@/lib/mothership/chat/display-message'
15+
import { normalizeMessage } from '@/lib/mothership/chat/persisted-message'
1416
import { SIM_SELECTION_MIME } from '@/lib/mothership/chat/selection-clipboard'
1517
import {
1618
type UsePromptEditorProps,
@@ -531,6 +533,51 @@ it('re-chipifies a seeded draft once skills load', () => {
531533
})
532534

533535
describe('sent prompt history', () => {
536+
it.each([
537+
['workspace ownership', { kind: 'file', fileId: 'file-a', workspaceId: 42 }],
538+
[
539+
'conflicting file owner',
540+
{
541+
kind: 'file',
542+
fileId: 'file-a',
543+
workspaceId: 'ws-1',
544+
owner: { entityType: 'workspace', entityId: 'ws-2' },
545+
},
546+
],
547+
['saved table view', { kind: 'table', tableId: 'table-a', viewId: ['view-a'] }],
548+
['empty browser selection', { kind: 'browser_tab', tabId: 'tab-a', selection: { text: '' } }],
549+
[
550+
'terminal selection bounds',
551+
{
552+
kind: 'terminal_tab',
553+
terminalId: 'terminal-a',
554+
selection: { text: 'output', startLine: 10_000_001, endLine: 10_000_002 },
555+
},
556+
],
557+
])('recalls text and valid chips when saved %s is malformed', (_name, context) => {
558+
const valid = { kind: 'file', fileId: 'file-b', label: 'Valid' }
559+
const message = toDisplayMessage(
560+
normalizeMessage({
561+
id: 'stored',
562+
role: 'user',
563+
content: 'Compare @Invalid and @Valid',
564+
contexts: [{ ...context, label: 'Invalid' }, valid],
565+
})
566+
)
567+
const { result, unmount } = renderPromptEditor({ workspaceId: 'ws-1' })
568+
try {
569+
act(() => {
570+
expect(
571+
result().restorePrompt(message.content, message.contexts).hasUnavailableContexts
572+
).toBe(true)
573+
})
574+
expect(result().getPlainValue()).toBe('Compare @Invalid and @Valid')
575+
expect(result().getActiveContexts()).toEqual([valid])
576+
} finally {
577+
unmount()
578+
}
579+
})
580+
534581
it('recalls only user messages, walks both ways, and returns to the empty composer', () => {
535582
const history = renderHistoryEditor([
536583
{ id: 'first', role: 'user', content: 'first\nsecond line' },

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getMothershipAttachmentPreviewUrl,
55
getMothershipAttachmentUrl,
66
} from '@/lib/mothership/chat/attachment-preview'
7+
import { UNAVAILABLE_CONTEXT_NOTICE } from '@/lib/mothership/chat/context'
78
import type { PromptEditorInstance } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor'
89
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
910
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
@@ -83,7 +84,7 @@ export function usePromptHistory(props: UsePromptHistoryProps) {
8384
message.contexts
8485
)
8586
if (hasUnavailableContexts) {
86-
toast.info('Some context in this message was not saved. Reattach it before sending.')
87+
toast.info(UNAVAILABLE_CONTEXT_NOTICE)
8788
}
8889
restoreAttachedFiles(files)
8990
editor.pendingCursorRef.current = direction === -1 ? 0 : value.length

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Paperclip, Plus, Slash } from '@sim/emcn/icons'
1515
import { createLogger } from '@sim/logger'
1616
import { useParams } from 'next/navigation'
1717
import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview'
18+
import { UNAVAILABLE_CONTEXT_NOTICE } from '@/lib/mothership/chat/context'
1819
import { MOTHERSHIP_ADD_CONTEXT_EVENT } from '@/lib/mothership/events'
1920
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types'
2021
import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
@@ -370,7 +371,8 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
370371
loadQueuedMessage: (msg: QueuedMessage) => {
371372
const currentEditor = editorRef.current
372373
history.reset()
373-
currentEditor.restorePrompt(msg.content, msg.contexts)
374+
const { hasUnavailableContexts } = currentEditor.restorePrompt(msg.content, msg.contexts)
375+
if (hasUnavailableContexts) toast.info(UNAVAILABLE_CONTEXT_NOTICE)
374376
const restored: AttachedFile[] = (msg.fileAttachments ?? []).map((a) => ({
375377
id: a.id,
376378
name: a.filename,

‎apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.test.ts‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
/** @vitest-environment jsdom */
22
import { beforeEach, expect, it } from 'vitest'
3+
import { MAX_CHAT_CONTEXTS } from '@/lib/mothership/chat/context-limits'
34
import { STREAM_STORAGE_KEY } from '@/lib/mothership/constants'
4-
import { readQueuedSendHandoffState, writeQueuedSendHandoffState } from './send-handoff'
5+
import {
6+
readQueuedSendHandoffState,
7+
writeQueuedSendHandoffState,
8+
} from '@/app/workspace/[workspaceId]/home/hooks/send-handoff'
59

610
beforeEach(() => sessionStorage.clear())
711
const seed = () => ({
@@ -33,3 +37,76 @@ it('rejects invalid levels and preserves an explicit Build mode', () => {
3337
writeQueuedSendHandoffState({ ...seed(), requestMode: 'agent' })
3438
expect(readQueuedSendHandoffState()).toMatchObject({ requestMode: 'agent' })
3539
})
40+
41+
it('keeps lost-context evidence when a recovered handoff is written again', () => {
42+
const validContext = { kind: 'docs' as const, label: 'Docs' }
43+
writeQueuedSendHandoffState({
44+
...seed(),
45+
contexts: [
46+
validContext,
47+
{
48+
kind: 'browser_tab',
49+
tabId: 'tab-a',
50+
label: 'Browser',
51+
selection: { text: 'Selected passage', title: 'x'.repeat(513) },
52+
},
53+
],
54+
})
55+
const recovered = readQueuedSendHandoffState()
56+
expect(recovered).toMatchObject({ contexts: [validContext], hasUnavailableContexts: true })
57+
if (!recovered) throw new Error('Expected a recoverable handoff')
58+
writeQueuedSendHandoffState(recovered)
59+
expect(readQueuedSendHandoffState()).toMatchObject({
60+
contexts: [validContext],
61+
hasUnavailableContexts: true,
62+
})
63+
})
64+
65+
it.each([null, {}, 'invalid'])(
66+
'keeps the message for review when stored contexts are malformed (%s)',
67+
(contexts) => {
68+
sessionStorage.setItem(
69+
`${STREAM_STORAGE_KEY}:queued-send-handoff`,
70+
JSON.stringify({ ...seed(), contexts })
71+
)
72+
expect(readQueuedSendHandoffState()).toMatchObject({
73+
message: 'Search',
74+
hasUnavailableContexts: true,
75+
})
76+
}
77+
)
78+
79+
it.each([100, 101])(
80+
'recovers %s contexts within the send limit without losing review state',
81+
(count) => {
82+
const contexts = Array.from({ length: count }, (_, index) => ({
83+
kind: 'file' as const,
84+
fileId: `file-${index}`,
85+
label: `File ${index}`,
86+
}))
87+
writeQueuedSendHandoffState({ ...seed(), contexts })
88+
const recovered = readQueuedSendHandoffState()
89+
expect(recovered?.contexts).toEqual(contexts.slice(0, MAX_CHAT_CONTEXTS))
90+
expect(recovered?.message).toBe('Search')
91+
expect(recovered?.hasUnavailableContexts).toBe(count > MAX_CHAT_CONTEXTS ? true : undefined)
92+
if (!recovered) throw new Error('Expected a recoverable handoff')
93+
writeQueuedSendHandoffState(recovered)
94+
expect(readQueuedSendHandoffState()).toEqual(recovered)
95+
}
96+
)
97+
98+
it('keeps a full limit of valid contexts when an earlier stored context is invalid', () => {
99+
const contexts = Array.from({ length: MAX_CHAT_CONTEXTS }, (_, index) => ({
100+
kind: 'file' as const,
101+
fileId: `file-${index}`,
102+
label: `File ${index}`,
103+
}))
104+
sessionStorage.setItem(
105+
`${STREAM_STORAGE_KEY}:queued-send-handoff`,
106+
JSON.stringify({
107+
...seed(),
108+
contexts: [{ kind: 'workspace', label: 'Missing address' }, ...contexts],
109+
})
110+
)
111+
expect(readQueuedSendHandoffState()).toMatchObject({ contexts, hasUnavailableContexts: true })
112+
})

‎apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isChatContext } from '@/lib/mothership/chat/context'
2+
import { MAX_CHAT_CONTEXTS } from '@/lib/mothership/chat/context-limits'
23
/**
34
* Queued-send handoff (revamp M6 extraction, behavior-preserving): the sessionStorage
45
* claim machinery that carries a queued outgoing message across a chat-scope handoff —
@@ -35,6 +36,7 @@ export interface QueuedSendHandoffState extends QueuedSendHandoffSeed {
3536
message: string
3637
fileAttachments?: FileAttachmentForApi[]
3738
contexts?: ChatContext[]
39+
hasUnavailableContexts?: boolean
3840
requestMode?: ChatRequestMode
3941
assistantSearch?: WorkspaceSearchFilters
4042
assistantSearchLevel?: AssistantSearchLevel
@@ -108,6 +110,14 @@ export function readQueuedSendHandoffState(): QueuedSendHandoffState | null {
108110
const assistantSearch = workspaceSearchFiltersSchema.safeParse(parsed.assistantSearch ?? {})
109111
if (!assistantSearch.success) return null
110112

113+
const contexts = Array.isArray(parsed.contexts)
114+
? parsed.contexts.filter(isChatContext).slice(0, MAX_CHAT_CONTEXTS)
115+
: undefined
116+
const hasUnavailableContexts =
117+
parsed.hasUnavailableContexts === true ||
118+
(parsed.contexts !== undefined &&
119+
(!Array.isArray(parsed.contexts) || contexts?.length !== parsed.contexts.length))
120+
111121
return {
112122
id: parsed.id,
113123
...(chatId ? { chatId } : {}),
@@ -123,9 +133,8 @@ export function readQueuedSendHandoffState(): QueuedSendHandoffState | null {
123133
...(Array.isArray(parsed.fileAttachments)
124134
? { fileAttachments: parsed.fileAttachments.filter(isFileAttachmentForApi) }
125135
: {}),
126-
...(Array.isArray(parsed.contexts)
127-
? { contexts: parsed.contexts.filter(isChatContext) }
128-
: {}),
136+
...(contexts ? { contexts } : {}),
137+
...(hasUnavailableContexts ? { hasUnavailableContexts: true } : {}),
129138
...(parsed.requestMode === 'assistant' ||
130139
parsed.requestMode === 'agent' ||
131140
parsed.requestMode === 'plan'

0 commit comments

Comments
 (0)