Skip to content

Commit 861eea1

Browse files
committed
fix(chat): send a message with a search scope as its own turn, never steered
The steer body has no search scope, so a Summarize sent during a turn ran without its document scope. canSteer now refuses such a message, and the path that steers a send the chat refused as busy goes through it too, since it skips the step's gate.
1 parent 4c7c3b7 commit 861eea1

4 files changed

Lines changed: 93 additions & 3 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,15 @@ describe('transitions', () => {
263263
expect(notBefore).toBeGreaterThan(NOW)
264264
})
265265

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+
266275
it('clears the retry delay on every move to not delivered or applied', () => {
267276
const delayed = { notBefore: NOW - 1 }
268277
const context = { streamId: 'turn', turnErrored: false, now: NOW }

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

Lines changed: 9 additions & 3 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. */

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.dom.test.tsx‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4415,6 +4415,75 @@ describe('useChat remount send recovery', () => {
44154415
}
44164416
}, 30_000)
44174417

4418+
const SUMMARY_SCOPE = { startDate: '2026-11-01T00:00:00.000Z' }
4419+
4420+
it('holds a message with a search scope for its own turn, keeping the scope', async () => {
4421+
const { view, turn } = await startTurn()
4422+
await act(async () => {
4423+
await view.getResult().sendMessage('Summarize this', undefined, undefined, {
4424+
assistantSearch: SUMMARY_SCOPE,
4425+
})
4426+
await settle()
4427+
})
4428+
4429+
expect(state.steerBodies).toHaveLength(0)
4430+
expect(view.getResult().messages.at(-1)).toMatchObject({
4431+
content: 'Summarize this',
4432+
delivery: 'waiting',
4433+
})
4434+
4435+
await turn.end()
4436+
await waitFor(() => state.postBodies.length === 2)
4437+
expect(state.steerBodies).toHaveLength(0)
4438+
expect(state.postBodies[1]).toMatchObject({
4439+
message: 'Summarize this',
4440+
assistantSearch: SUMMARY_SCOPE,
4441+
})
4442+
})
4443+
4444+
/** A turn this surface did not know of refuses the send; steering it would drop the scope. */
4445+
it('never steers a message with a search scope into a turn that refused its send', async () => {
4446+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
4447+
try {
4448+
mockRequestJson.mockImplementation(() => Promise.resolve({ chat: history }))
4449+
let busy = true
4450+
vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => {
4451+
if (String(input) === '/api/mothership/chat' && init?.method === 'POST') {
4452+
state.postBodies.push(JSON.parse(String(init.body)))
4453+
return busy
4454+
? Response.json(
4455+
{ error: 'A response is already in progress.', activeStreamId: 'other-tab-run' },
4456+
{ status: 409 }
4457+
)
4458+
: emptySseResponse()
4459+
}
4460+
return fetchStub(input, init)
4461+
})
4462+
state.steerDispositions = ['queued', 'queued']
4463+
const view = renderUseChatInChat(chatId, history)
4464+
await act(async () => {
4465+
void view.getResult().sendMessage('Summarize this', undefined, undefined, {
4466+
assistantSearch: SUMMARY_SCOPE,
4467+
})
4468+
await vi.advanceTimersByTimeAsync(50)
4469+
})
4470+
expect(state.postBodies).toHaveLength(1)
4471+
expect(state.steerBodies).toHaveLength(0)
4472+
4473+
busy = false
4474+
await act(async () => vi.advanceTimersByTimeAsync(3_000))
4475+
4476+
expect(state.steerBodies).toHaveLength(0)
4477+
expect(state.postBodies.length).toBeGreaterThan(1)
4478+
expect(state.postBodies.at(-1)).toMatchObject({
4479+
message: 'Summarize this',
4480+
assistantSearch: SUMMARY_SCOPE,
4481+
})
4482+
} finally {
4483+
vi.useRealTimers()
4484+
}
4485+
})
4486+
44184487
/** It waits for its marker in the saved transcript, which no timer brings any sooner. */
44194488
it('stops waking for a message read as applied after an earlier read held it', async () => {
44204489
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import {
110110
afterDispositionRead,
111111
afterRetry,
112112
afterUnsentAttempt,
113+
canSteer,
113114
deliveryOf,
114115
fromJournaled,
115116
hasOutgoingFollowUp,
@@ -4587,6 +4588,11 @@ export function useChat(
45874588
return
45884589
}
45894590
updateOutgoing(message.id, { attempts })
4591+
if (!canSteer(message.payload)) {
4592+
updateOutgoing(message.id, { notBefore: Date.now() + outgoingRetryDelay(attempts) })
4593+
deliverOutgoingRef.current()
4594+
return
4595+
}
45904596
void steerOutgoing({ ...message, attempts }, result.streamId).then(() => {
45914597
const current = outgoingRef.current.find((candidate) => candidate.id === message.id)
45924598
if (current?.status === 'pending') {

0 commit comments

Comments
 (0)