Skip to content

Commit d48787f

Browse files
committed
Merge branch 'wip/next-steering-fixes' into wip/platform-org-wiring
2 parents 2ac7622 + 092a6fc commit d48787f

12 files changed

Lines changed: 808 additions & 32 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ import {
6565
} from '@/app/workspace/[workspaceId]/home/components/user-input'
6666
import type { ModelSelectorOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
6767
import { UserMessageContent } from '@/app/workspace/[workspaceId]/home/components/user-message-content'
68-
import { findLiveResponseId } from '@/app/workspace/[workspaceId]/home/hooks/steered-messages'
68+
import {
69+
findLastConversationIndex,
70+
findLiveResponseId,
71+
} from '@/app/workspace/[workspaceId]/home/hooks/steered-messages'
6972
import {
7073
fetchWorkspaceResourceRef,
7174
readCachedWorkspaceResourceRef,
@@ -746,6 +749,7 @@ export function MothershipChat({
746749
}
747750
}
748751
const lastIndex = messages.length - 1
752+
const lastConversationIndex = findLastConversationIndex(messages)
749753
const lastRowKey = lastIndex >= 0 ? rowKeyByIndex[lastIndex] : undefined
750754
useEffect(() => {
751755
setLastRowAnimating(false)
@@ -975,7 +979,7 @@ export function MothershipChat({
975979
{virtualItems.map((virtualItem) => {
976980
const index = virtualItem.index
977981
const msg = messages[index]
978-
const isLast = index === lastIndex
982+
const isLast = index === lastConversationIndex
979983
return (
980984
<div
981985
key={virtualItem.key}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* Messages on their way into a turn, or shown as not delivered, render after its answer. Failure
5+
* modes: the running answer loses its thinking indicator because a steered message trails it (an
6+
* empty one disappears altogether); the last answer's choices stop responding because a message
7+
* not delivered trails it; a real next message leaves the old answer's choices live.
8+
*/
9+
import { act } from 'react'
10+
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
11+
import { libDesktopMock } from '@sim/testing/mocks/lib-desktop.mock'
12+
import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
13+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
14+
import { createRoot, type Root } from 'react-dom/client'
15+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
16+
import { FeatureFlagsProvider } from '@/lib/core/config/feature-flags-context'
17+
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat'
18+
import type { ChatMessage } from '@/app/workspace/[workspaceId]/home/types'
19+
20+
vi.mock('next/navigation', () => nextNavigationMock)
21+
vi.mock('@/lib/desktop', () => libDesktopMock)
22+
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
23+
24+
const owner: ChatMessage = { id: 'turn', role: 'user', content: 'Count' }
25+
const steered: ChatMessage = {
26+
id: 'steer',
27+
role: 'user',
28+
content: 'Stop at two',
29+
steering: { streamId: 'turn' },
30+
delivery: 'sending',
31+
}
32+
const notDelivered: ChatMessage = {
33+
id: 'failed',
34+
role: 'user',
35+
content: 'Then summarize',
36+
delivery: 'failed',
37+
}
38+
const choices: ChatMessage = {
39+
id: 'answer',
40+
role: 'assistant',
41+
content: '',
42+
contentBlocks: [{ type: 'options', options: [{ id: '1', label: 'Pick this one' }] }],
43+
}
44+
45+
let root: Root
46+
let container: HTMLDivElement
47+
let client: QueryClient
48+
const onSubmit = vi.fn()
49+
50+
beforeEach(() => {
51+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
52+
vi.stubGlobal(
53+
'ResizeObserver',
54+
class {
55+
observe() {}
56+
unobserve() {}
57+
disconnect() {}
58+
}
59+
)
60+
vi.stubGlobal('matchMedia', () => ({
61+
matches: true,
62+
addEventListener: vi.fn(),
63+
removeEventListener: vi.fn(),
64+
}))
65+
/* The virtualizer renders nothing into a viewport with no size. */
66+
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(2_000)
67+
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800)
68+
onSubmit.mockReset()
69+
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
70+
container = document.createElement('div')
71+
document.body.append(container)
72+
root = createRoot(container)
73+
})
74+
75+
afterEach(async () => {
76+
await act(async () => root.unmount())
77+
client.clear()
78+
container.remove()
79+
})
80+
81+
async function renderChat(messages: ChatMessage[], isSending: boolean) {
82+
await act(async () =>
83+
root.render(
84+
<QueryClientProvider client={client}>
85+
<FeatureFlagsProvider
86+
flags={{
87+
dashboards: false,
88+
'workflow-tests': false,
89+
changelog: false,
90+
'mothership-model-selector': false,
91+
'mothership-plan-mode': false,
92+
'mothership-computer-use': false,
93+
}}
94+
>
95+
<MothershipChat
96+
messages={messages}
97+
isSending={isSending}
98+
onSubmit={onSubmit}
99+
onStopGeneration={vi.fn()}
100+
composer={<div />}
101+
/>
102+
</FeatureFlagsProvider>
103+
</QueryClientProvider>
104+
)
105+
)
106+
}
107+
108+
function findOption(): HTMLElement {
109+
const option = [...container.querySelectorAll('button')].find((button) =>
110+
button.textContent?.includes('Pick this one')
111+
)
112+
if (!option) throw new Error('The answer rendered no choice')
113+
return option
114+
}
115+
116+
describe('rows trailing an answer', () => {
117+
it('keeps the thinking indicator on a running answer a steered message trails', async () => {
118+
await renderChat(
119+
[owner, { id: 'live', role: 'assistant', content: '', contentBlocks: [] }, steered],
120+
true
121+
)
122+
expect(container.textContent).toContain('Stop at two')
123+
expect(container.textContent).toContain('Thinking')
124+
})
125+
126+
it('keeps the last answer’s choices live under a message shown as not delivered', async () => {
127+
await renderChat([owner, choices, notDelivered], false)
128+
await act(async () => findOption().click())
129+
expect(onSubmit).toHaveBeenCalledOnce()
130+
})
131+
132+
it('leaves an answer’s choices dead once a real next message follows it', async () => {
133+
await renderChat(
134+
[owner, choices, { id: 'next', role: 'user', content: 'Something else' }],
135+
false
136+
)
137+
await act(async () => findOption().click())
138+
expect(onSubmit).not.toHaveBeenCalled()
139+
})
140+
})

‎apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,10 @@ export function findStreamOwnerIndex(messages: PersistedMessage[], streamId: str
248248
}
249249

250250
/** The turn's response after its owner, past any steering the user sent into that turn. */
251-
export function findAssistantAfterOwner(messages: PersistedMessage[], ownerIndex: number): number {
251+
export function findAssistantAfterOwner(
252+
messages: ReadonlyArray<Pick<PersistedMessage, 'role' | 'steering'>>,
253+
ownerIndex: number
254+
): number {
252255
for (let index = ownerIndex + 1; index < messages.length; index++) {
253256
const message = messages[index]
254257
if (message.role === 'user' && !message.steering) return -1

‎apps/sim/app/workspace/[workspaceId]/home/hooks/outgoing-state.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
isDeliveredAsTurn,
2525
MAX_OUTGOING_DELIVERY_ATTEMPTS,
2626
nextOutgoingStep,
27+
nextOutgoingWake,
2728
ORPHAN_AUTO_SEND_MAX_AGE_MS,
2829
type OutgoingMessage,
2930
orphanSteeringRows,
@@ -262,6 +263,54 @@ describe('transitions', () => {
262263
expect(notBefore).toBeGreaterThan(NOW)
263264
})
264265

266+
it('never steers a message with a search scope, and shows it waits for its own turn', () => {
267+
const scoped = entry('a', {
268+
payload: { content: 'Summarize', assistantSearch: { startDate: '2026-11-01T00:00:00.000Z' } },
269+
})
270+
expect(nextOutgoingStep([scoped], view({ liveStreamId: 'turn' })).kind).toBe('idle')
271+
expect(deliveryOf(scoped, true)).toBe('waiting')
272+
expect(nextOutgoingStep([scoped], view())).toMatchObject({ kind: 'send', message: scoped })
273+
})
274+
275+
it('clears the retry delay on every move to not delivered or applied', () => {
276+
const delayed = { notBefore: NOW - 1 }
277+
const context = { streamId: 'turn', turnErrored: false, now: NOW }
278+
const steered = entry('a', { status: 'steered', streamId: 'turn', ...delayed })
279+
for (const patch of [
280+
afterUnsentAttempt(entry('a', { attempts: MAX_OUTGOING_DELIVERY_ATTEMPTS - 1, ...delayed }), {
281+
online: true,
282+
now: NOW,
283+
}),
284+
afterDispositionRead({ ...steered, reads: 7 }, { status: 'queued' }, context),
285+
afterDispositionRead(steered, { status: 'applied' }, context),
286+
afterDispositionRead(
287+
{ ...steered, attempts: 2 },
288+
{ status: 'not_applied' },
289+
{ ...context, turnErrored: true }
290+
),
291+
]) {
292+
expect('notBefore' in patch && patch.notBefore === undefined).toBe(true)
293+
}
294+
})
295+
296+
it('wakes only for a delay the last pass could have skipped', () => {
297+
const wake = (message: OutgoingMessage) => nextOutgoingWake([message], NOW)
298+
for (const status of ['pending', 'steered'] as const) {
299+
expect(wake(entry('a', { status, notBefore: NOW + 5 }))).toBe(NOW + 5)
300+
expect(wake(entry('a', { status, notBefore: NOW }))).toBe(Number.POSITIVE_INFINITY)
301+
}
302+
for (const status of ['failed', 'applied'] as const) {
303+
expect(wake(entry('a', { status, notBefore: NOW + 5 }))).toBe(Number.POSITIVE_INFINITY)
304+
}
305+
expect(wake(entry('a'))).toBe(Number.POSITIVE_INFINITY)
306+
expect(
307+
nextOutgoingWake(
308+
[entry('a', { notBefore: NOW + 9 }), entry('b', { notBefore: NOW + 3 })],
309+
NOW
310+
)
311+
).toBe(NOW + 3)
312+
})
313+
265314
it('leaves reads out of the budget for sends', () => {
266315
const read = afterDispositionRead(
267316
entry('a', { status: 'steered', streamId: 'turn' }),

‎apps/sim/app/workspace/[workspaceId]/home/hooks/outgoing-state.ts‎

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,16 @@ export function optimisticUserMessageFor(id: string, payload: SendPayload): Chat
126126

127127
/**
128128
* Whether a message can be steered: steering carries its files and mentions like a
129-
* turn, but needs text, so a message of files alone goes out as a turn.
129+
* turn, but needs text, so a message of files alone goes out as a turn. It has no
130+
* search scope, so a message asking for one (a Summarize of found documents) goes
131+
* out as a turn too, rather than running without it.
130132
*/
131-
function canSteer(payload: SendPayload): boolean {
132-
return payload.content.trim().length > 0 && payload.content.length <= MAX_STEERING_CONTENT_LENGTH
133+
export function canSteer(payload: SendPayload): boolean {
134+
return (
135+
!payload.assistantSearch &&
136+
payload.content.trim().length > 0 &&
137+
payload.content.length <= MAX_STEERING_CONTENT_LENGTH
138+
)
133139
}
134140

135141
/** The journal entry an outgoing message is restored from; a steer in flight restores as sent. */
@@ -183,6 +189,29 @@ export function outgoingRetryDelay(attempts: number): number {
183189
})
184190
}
185191

192+
/**
193+
* Where the rest of a new chat's messages wait once its surface is gone: the handoff
194+
* carries one message, and whichever surface resumes that one takes these behind it.
195+
*/
196+
export function handedOffJournalKey(resumedId: string): string {
197+
return `handoff:${resumedId}`
198+
}
199+
200+
/**
201+
* What a new chat's message goes on to the next surface as. Nothing took it, since
202+
* its chat never existed, so it starts over; one shown as not delivered stays that
203+
* way until the user asks.
204+
*/
205+
export function toHandedOff(message: OutgoingMessage): JournaledOutgoing {
206+
return {
207+
id: message.id,
208+
payload: message.payload,
209+
status: message.status === 'failed' ? 'failed' : 'pending',
210+
...(message.unconfirmed ? { unconfirmed: true } : {}),
211+
writtenAt: message.writtenAt,
212+
}
213+
}
214+
186215
type PersistedRow = MothershipChatHistory['messages'][number]
187216

188217
/**
@@ -273,6 +302,7 @@ export function notSteered(streamId: string, turnErrored: boolean): Partial<Outg
273302
refusedBy: streamId,
274303
streamId: undefined,
275304
unconfirmed: false,
305+
notBefore: undefined,
276306
}
277307
}
278308

@@ -283,7 +313,7 @@ export function afterUnsentAttempt(
283313
): Partial<OutgoingMessage> {
284314
const attempts = message.attempts + 1
285315
if (attempts >= MAX_OUTGOING_DELIVERY_ATTEMPTS) {
286-
return { status: 'failed', attempts, unconfirmed: true }
316+
return { status: 'failed', attempts, unconfirmed: true, notBefore: undefined }
287317
}
288318
return {
289319
status: 'pending',
@@ -304,13 +334,15 @@ export function afterDispositionRead(
304334
if (outcome.status === 'applied') {
305335
return {
306336
status: 'applied',
337+
notBefore: undefined,
307338
...(outcome.textOffset !== undefined ? { textOffset: outcome.textOffset } : {}),
308339
}
309340
}
310341
if (outcome.status === 'not_applied') {
342+
const refused = notSteered(context.streamId, context.turnErrored || message.noAutoSend === true)
311343
return {
312-
...notSteered(context.streamId, context.turnErrored || message.noAutoSend === true),
313-
...(message.attempts > 0
344+
...refused,
345+
...(refused.status === 'pending' && message.attempts > 0
314346
? { notBefore: context.now + outgoingRetryDelay(message.attempts) }
315347
: {}),
316348
}
@@ -319,14 +351,33 @@ export function afterDispositionRead(
319351
as one whose owner died, does not keep it waiting forever. */
320352
const reads = message.reads + 1
321353
return reads >= MAX_DISPOSITION_READS
322-
? { status: 'failed', reads }
354+
? { status: 'failed', reads, notBefore: undefined }
323355
: {
324356
reads,
325357
unconfirmed: outcome.status === 'unknown',
326358
notBefore: context.now + outgoingRetryDelay(reads),
327359
}
328360
}
329361

362+
/**
363+
* When a delivery pass next has to run for a chat's messages: the earliest delay
364+
* that was still running when the last pass began, so that pass could have skipped
365+
* it. A delay already over by then decides nothing: what still waits after it waits
366+
* on a turn's end or the network, which wake delivery themselves, and arming for it
367+
* would run passes back to back until they do. Infinite when nothing is due.
368+
*/
369+
export function nextOutgoingWake(inChat: readonly OutgoingMessage[], passBegan: number): number {
370+
return Math.min(
371+
...inChat.flatMap((message) =>
372+
(message.status === 'pending' || message.status === 'steered') &&
373+
message.notBefore !== undefined &&
374+
message.notBefore > passBegan
375+
? [message.notBefore]
376+
: []
377+
)
378+
)
379+
}
380+
330381
/**
331382
* The user asked again: one steered reads its disposition again rather than going
332383
* out twice, and what never reached a turn goes out, through the steering gate if a

‎apps/sim/app/workspace/[workspaceId]/home/hooks/steered-messages.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ export function findLiveResponseId(messages: readonly ChatMessage[]): string | u
7878
return undefined
7979
}
8080

81+
/**
82+
* The last row of the conversation proper: messages on their way into a turn, or
83+
* shown as not delivered, trail it without being its end. The answer here is the one
84+
* still thinking, and the one whose choices can still be picked.
85+
*/
86+
export function findLastConversationIndex(messages: readonly ChatMessage[]): number {
87+
for (let index = messages.length - 1; index >= 0; index--) {
88+
if (!messages[index].steering && !messages[index].delivery) return index
89+
}
90+
return -1
91+
}
92+
8193
const ANSWER_SEGMENT_MARKER = ':before:'
8294

8395
/**

0 commit comments

Comments
 (0)