Skip to content

Commit e30ba7c

Browse files
author
Vikhyath Mondreti
committed
fix(plan): retry retained completions and guard benchmark drafts
1 parent d4690be commit e30ba7c

6 files changed

Lines changed: 369 additions & 7 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import { flushMicrotasks } from '@sim/testing/helpers/async'
4+
import { jsonResponse } from '@sim/testing/helpers/http'
5+
import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock'
6+
import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
7+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
8+
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
9+
import { createRoot, type Root } from 'react-dom/client'
10+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
11+
import { SettingsNavigationGuard } from '@/components/settings/settings-navigation-guard'
12+
import { type BenchmarkCase, updateBenchmarkContract } from '@/lib/api/contracts/benchmarks'
13+
import { BenchmarkConsole } from '@/app/benchmark/benchmark-console'
14+
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
15+
16+
vi.mock('@/lib/auth/auth-client', () => authClientMock)
17+
vi.mock('next/navigation', () => nextNavigationMock)
18+
19+
let root: Root
20+
let container: HTMLDivElement
21+
let client: QueryClient
22+
let saved: BenchmarkCase
23+
let patches: unknown[]
24+
let failSave: boolean
25+
26+
async function settle(action: () => void) {
27+
await act(async () => {
28+
action()
29+
await flushMicrotasks()
30+
})
31+
await act(async () => {
32+
await vi.advanceTimersByTimeAsync(100)
33+
await flushMicrotasks()
34+
})
35+
}
36+
37+
function button(name: string) {
38+
const value = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
39+
(node) => node.textContent?.trim() === name
40+
)
41+
if (!value) throw new Error(`Missing button ${name}`)
42+
return value
43+
}
44+
45+
function field(selector: string) {
46+
const value = document.querySelector<HTMLTextAreaElement>(selector)
47+
if (!value) throw new Error(`Missing field ${selector}`)
48+
return value
49+
}
50+
51+
async function edit(selector: string, value: string) {
52+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
53+
if (!setter) throw new Error('Missing textarea setter')
54+
await settle(() => {
55+
const target = field(selector)
56+
setter.call(target, value)
57+
target.dispatchEvent(new Event('input', { bubbles: true }))
58+
})
59+
}
60+
61+
beforeEach(async () => {
62+
vi.useFakeTimers()
63+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
64+
authClientMockFns.mockUseSession.mockReturnValue({
65+
data: { user: { id: 'operator' } },
66+
isPending: false,
67+
error: null,
68+
})
69+
useSettingsDirtyStore.getState().reset()
70+
patches = []
71+
failSave = false
72+
saved = {
73+
id: 'case',
74+
organizationId: 'org',
75+
userId: 'operator',
76+
runAsUserId: 'user',
77+
sourceWorkspaceId: 'workspace',
78+
name: 'Fixture benchmark',
79+
version: 1,
80+
runningStage: null,
81+
leaseExpiresAt: null,
82+
attemptId: null,
83+
plannerChatId: null,
84+
error: null,
85+
createdAt: '2026-01-01T00:00:00.000Z',
86+
updatedAt: '2026-01-01T00:00:00.000Z',
87+
artifacts: {
88+
taskBrief: 'Prepare a workflow',
89+
referenceSpec: 'Send the report to Operations.',
90+
redactedSpec: '',
91+
blanks: [],
92+
generatedSpec: null,
93+
reconstruction: null,
94+
grade: null,
95+
},
96+
}
97+
vi.stubGlobal(
98+
'fetch',
99+
vi.fn<typeof fetch>(async (input, init) => {
100+
const url = new URL(String(input), 'http://localhost')
101+
if (url.pathname === '/api/benchmarks/organizations')
102+
return jsonResponse({
103+
organizations: [{ id: 'other-org', name: 'Other organization' }],
104+
selected: { id: 'org', name: 'Fixture organization' },
105+
nextCursor: null,
106+
})
107+
if (url.pathname.endsWith('/users'))
108+
return jsonResponse({
109+
users: [{ id: 'other-user', name: 'Other user', email: 'other@example.test' }],
110+
selected: { id: 'user', name: 'Fixture user', email: 'fixture@example.test' },
111+
nextCursor: null,
112+
})
113+
if (url.pathname.endsWith('/workspaces'))
114+
return jsonResponse({
115+
workspaces: [{ id: 'workspace', name: 'Fixture workspace' }],
116+
canPlan: true,
117+
nextCursor: null,
118+
})
119+
if (url.pathname.endsWith('/runs')) return jsonResponse({ runs: [], nextCursor: null })
120+
if (url.pathname.endsWith('/benchmarks'))
121+
return jsonResponse({ benchmarks: [saved], nextCursor: null })
122+
if (url.pathname.endsWith('/benchmarks/case')) {
123+
if (init?.method === 'PATCH') {
124+
const schema = updateBenchmarkContract.body
125+
if (!schema) throw new Error('Missing benchmark update contract')
126+
const patch = schema.parse(JSON.parse(String(init.body)))
127+
patches.push(patch)
128+
if (failSave) return jsonResponse({ error: 'Save unavailable' }, 503)
129+
const { version, name, ...artifacts } = patch
130+
saved = {
131+
...saved,
132+
name: name ?? saved.name,
133+
version: version + 1,
134+
artifacts: { ...saved.artifacts, ...artifacts },
135+
}
136+
}
137+
return jsonResponse({ benchmark: saved })
138+
}
139+
throw new Error(`Unexpected fixture request ${url.pathname}`)
140+
})
141+
)
142+
client = new QueryClient({
143+
defaultOptions: { queries: { retry: false, gcTime: Number.POSITIVE_INFINITY } },
144+
})
145+
container = document.createElement('div')
146+
document.body.append(container)
147+
root = createRoot(container)
148+
await settle(() =>
149+
root.render(
150+
<QueryClientProvider client={client}>
151+
<NuqsTestingAdapter searchParams='?organization=org&user=user&benchmark=case' hasMemory>
152+
<SettingsNavigationGuard />
153+
<BenchmarkConsole />
154+
</NuqsTestingAdapter>
155+
</QueryClientProvider>
156+
)
157+
)
158+
expect(field('#reference-spec').value).toBe(saved.artifacts.referenceSpec)
159+
})
160+
161+
afterEach(async () => {
162+
await act(async () => root.unmount())
163+
client.clear()
164+
container.remove()
165+
useSettingsDirtyStore.getState().reset()
166+
vi.useRealTimers()
167+
})
168+
169+
async function navigate(destination: string) {
170+
if (destination === 'Organization' || destination === 'Run as user') {
171+
const combo = document.querySelector<HTMLElement>(
172+
`[role="combobox"][aria-label="${destination}"]`
173+
)
174+
if (!combo) throw new Error(`Missing selection ${destination}`)
175+
await settle(() => combo.click())
176+
const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find((node) =>
177+
node.textContent?.includes(
178+
destination === 'Organization' ? 'Other organization' : 'Other user'
179+
)
180+
)
181+
if (!option) throw new Error(`Missing option ${destination}`)
182+
await settle(() => option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
183+
} else await settle(() => button(destination).click())
184+
}
185+
186+
it.each(['Run history', 'All benchmarks', 'New benchmark', 'Organization', 'Run as user'])(
187+
'keeps the reference until navigation to %s is explicitly discarded',
188+
async (destination) => {
189+
await edit('#reference-spec', 'Unsaved reference')
190+
await navigate(destination)
191+
expect(field('#reference-spec').value).toBe('Unsaved reference')
192+
await settle(() => button('Keep editing').click())
193+
expect(field('#reference-spec').value).toBe('Unsaved reference')
194+
expect(patches).toEqual([])
195+
await navigate(destination)
196+
await settle(() => button('Discard changes').click())
197+
expect(document.querySelector('#reference-spec')).toBeNull()
198+
expect(patches).toEqual([])
199+
expect(useSettingsDirtyStore.getState().isDirty).toBe(false)
200+
}
201+
)
202+
203+
it('keeps applied blanks through a cancelled navigation and failed save, then saves exact edits', async () => {
204+
await settle(() => button('Edit JSON').click())
205+
const textareas = [...document.querySelectorAll<HTMLTextAreaElement>('[role="dialog"] textarea')]
206+
expect(textareas).toHaveLength(2)
207+
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set
208+
if (!setter) throw new Error('Missing textarea setter')
209+
await settle(() => {
210+
for (const [index, value] of [
211+
'Send the report to [[BLANK:team]].',
212+
'{"team":"Operations"}',
213+
].entries()) {
214+
setter.call(textareas[index], value)
215+
textareas[index].dispatchEvent(new Event('input', { bubbles: true }))
216+
}
217+
})
218+
await settle(() => button('Apply mapping').click())
219+
await navigate('Run history')
220+
await settle(() => button('Keep editing').click())
221+
expect(field('[aria-label="Redacted reference spec"]').value).toBe(
222+
'Send the report to [[BLANK:team]].'
223+
)
224+
failSave = true
225+
await settle(() => button('Save changes').click())
226+
await navigate('All benchmarks')
227+
await settle(() => button('Keep editing').click())
228+
failSave = false
229+
await settle(() => button('Save changes').click())
230+
expect(patches).toEqual([
231+
{
232+
version: 1,
233+
redactedSpec: 'Send the report to [[BLANK:team]].',
234+
blanks: [{ id: 'team', answer: 'Operations' }],
235+
},
236+
{
237+
version: 1,
238+
redactedSpec: 'Send the report to [[BLANK:team]].',
239+
blanks: [{ id: 'team', answer: 'Operations' }],
240+
},
241+
])
242+
await navigate('Run history')
243+
await navigate('Current run')
244+
expect(field('[aria-label="Redacted reference spec"]').value).toBe(
245+
'Send the report to [[BLANK:team]].'
246+
)
247+
expect(useSettingsDirtyStore.getState().isDirty).toBe(false)
248+
})

‎apps/sim/app/benchmark/benchmark-console.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ import {
1515
useBenchmarkWorkspaces,
1616
} from '@/hooks/queries/benchmarks'
1717
import { useDebounce } from '@/hooks/use-debounce'
18+
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
1819

1920
export function BenchmarkConsole() {
2021
const [{ organizationId, runAsUserId }, setParams] = useQueryStates(
2122
benchmarkConsoleParams,
2223
benchmarkConsoleUrlOptions
2324
)
25+
const requestLeave = useSettingsDirtyStore((state) => state.requestLeave)
2426
const [organizationSearch, setOrganizationSearch] = useState('')
2527
const [userSearch, setUserSearch] = useState('')
2628
const organizations = useBenchmarkOrganizations(
@@ -70,7 +72,14 @@ export function BenchmarkConsole() {
7072
isLoading={organizations.isLoading}
7173
onSearchChange={setOrganizationSearch}
7274
onChange={(value) =>
73-
setParams({ organizationId: value, runAsUserId: null, ...emptyBenchmarkSelection })
75+
requestLeave(
76+
() =>
77+
void setParams({
78+
organizationId: value,
79+
runAsUserId: null,
80+
...emptyBenchmarkSelection,
81+
})
82+
)
7483
}
7584
/>
7685
{organizations.hasNextPage && (
@@ -100,7 +109,11 @@ export function BenchmarkConsole() {
100109
isLoading={users.isLoading}
101110
disabled={!organizationId}
102111
onSearchChange={setUserSearch}
103-
onChange={(value) => setParams({ runAsUserId: value, ...emptyBenchmarkSelection })}
112+
onChange={(value) =>
113+
requestLeave(
114+
() => void setParams({ runAsUserId: value, ...emptyBenchmarkSelection })
115+
)
116+
}
104117
/>
105118
{users.hasNextPage && (
106119
<Chip disabled={users.isFetchingNextPage} onClick={() => users.fetchNextPage()}>

‎apps/sim/app/o/[organizationId]/benchmark/benchmark.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
benchmarkUrlOptions,
1111
} from '@/app/o/[organizationId]/benchmark/search-params'
1212
import { useBenchmarks } from '@/hooks/queries/benchmarks'
13+
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
1314

1415
interface BenchmarkProps {
1516
organizationId: string
@@ -22,6 +23,7 @@ export function Benchmark({ organizationId, canPlan, runAsUserId }: BenchmarkPro
2223
benchmarkParams,
2324
benchmarkUrlOptions
2425
)
26+
const requestLeave = useSettingsDirtyStore((state) => state.requestLeave)
2527
const benchmarks = useBenchmarks(organizationId, runAsUserId)
2628
const records = benchmarks.data?.pages.flatMap((page) => page.benchmarks) ?? []
2729

@@ -36,12 +38,16 @@ export function Benchmark({ organizationId, canPlan, runAsUserId }: BenchmarkPro
3638
) : (
3739
<div className='flex items-center gap-2'>
3840
{benchmarkId && (
39-
<Chip onClick={() => setParams(emptyBenchmarkSelection)}>All benchmarks</Chip>
41+
<Chip onClick={() => requestLeave(() => void setParams(emptyBenchmarkSelection))}>
42+
All benchmarks
43+
</Chip>
4044
)}
4145
<Chip
4246
variant='primary'
4347
leftIcon={Plus}
44-
onClick={() => setParams({ ...emptyBenchmarkSelection, creating: true })}
48+
onClick={() =>
49+
requestLeave(() => void setParams({ ...emptyBenchmarkSelection, creating: true }))
50+
}
4551
>
4652
New benchmark
4753
</Chip>

‎apps/sim/app/o/[organizationId]/benchmark/components/benchmark-detail.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@sim/emcn'
1212
import { Download, Trash } from '@sim/emcn/icons'
1313
import { useQueryStates } from 'nuqs'
14+
import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
1415
import type {
1516
BenchmarkCase,
1617
RunBenchmarkComparisonBody,
@@ -41,6 +42,7 @@ import {
4142
useRunBenchmarkStage,
4243
useUpdateBenchmark,
4344
} from '@/hooks/queries/benchmarks'
45+
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
4446

4547
interface BenchmarkDetailProps {
4648
organizationId: string
@@ -108,6 +110,11 @@ function BenchmarkEditor({
108110
blank.id !== artifacts.blanks[index]?.id || blank.answer !== artifacts.blanks[index]?.answer
109111
)
110112
const dirty = referenceDirty || redactionDirty
113+
useSettingsUnsavedGuard({
114+
isDirty: dirty,
115+
navigationBlocked: saving,
116+
onDiscard: () => setEdit(null),
117+
})
111118
const hasPlannerInputs =
112119
artifacts.taskBrief.trim() &&
113120
artifacts.referenceSpec.trim() &&
@@ -319,6 +326,7 @@ export function BenchmarkDetail({
319326
const runStage = useRunBenchmarkStage(organizationId, benchmarkId)
320327
const deleteBenchmark = useDeleteBenchmark(organizationId, benchmarkId)
321328
const workspaces = useBenchmarkWorkspaces(organizationId, runAsUserId)
329+
const requestLeave = useSettingsDirtyStore((state) => state.requestLeave)
322330
const [deleteOpen, setDeleteOpen] = useState(false)
323331
const benchmark = benchmarkQuery.data?.benchmark
324332

@@ -419,7 +427,7 @@ export function BenchmarkDetail({
419427
</Chip>
420428
<Chip
421429
variant={benchmarkView === 'history' ? 'primary' : undefined}
422-
onClick={() => setParams({ benchmarkView: 'history' })}
430+
onClick={() => requestLeave(() => void setParams({ benchmarkView: 'history' }))}
423431
>
424432
Run history
425433
</Chip>

0 commit comments

Comments
 (0)