Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
87623e0
fix(chat): send entitlements and agent mode from the public chat API
TheodoreSpeaks Oct 8, 2026
c86b716
feat(tests): workflow tests as a workspace resource
TheodoreSpeaks Oct 8, 2026
780331e
feat(tests): pick a run from a dropdown and open what each run ran ag…
TheodoreSpeaks Oct 8, 2026
1e6fba4
Merge remote-tracking branch 'origin/staging' into feat/tests
TheodoreSpeaks Oct 8, 2026
2519405
fix(tests): type errors and tests broken by workflow tests
TheodoreSpeaks Oct 8, 2026
cd14f90
fix(tests): address review on redaction, staleness, and the harness
TheodoreSpeaks Oct 8, 2026
e3197cd
Merge remote-tracking branch 'origin/staging' into feat/tests
TheodoreSpeaks Oct 8, 2026
83a1daf
fix(tests): reserve run slots, await pending assertions, keep dynamic…
TheodoreSpeaks Oct 8, 2026
b5ed03e
fix(tests): keep test sources out of file tabs, refresh after runs, r…
TheodoreSpeaks Oct 8, 2026
e942dd4
fix(tests): refresh test tabs after a run, keep saved edits successful
TheodoreSpeaks Oct 8, 2026
825a1ad
fix(tests): starter source imports every test helper
TheodoreSpeaks Oct 8, 2026
1feb732
build(tests): add @vitest/expect and @vitest/spy for the sandbox bundle
TheodoreSpeaks Oct 8, 2026
f0d2f0b
build(tests): tell knip the sandbox bundle uses @vitest/expect and @v…
TheodoreSpeaks Oct 8, 2026
00ddb89
Merge remote-tracking branch 'origin/staging' into feat/tests
TheodoreSpeaks Oct 8, 2026
b7ecf79
feat(tests): name MCP and custom tool mocks by server and title
TheodoreSpeaks Oct 8, 2026
ca247e0
Merge remote-tracking branch 'origin/staging' into feat/tests
TheodoreSpeaks Oct 8, 2026
15dd811
fix(tests): reserve the run name, hold Run for unsaved edits, cancel …
TheodoreSpeaks Oct 8, 2026
21cc745
fix(tests): fail a run whose selected case names no test in the file
TheodoreSpeaks Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/sim/app/api/v2/chat/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ vi.mock('@/lib/mothership/chat/messages-store', () => mothershipChatMessagesMock
vi.mock('@/lib/mothership/chat/payload', () => mothershipChatPayloadMock)

vi.mock('@/lib/mothership/entitlements', () => ({
computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]),
computeEntitlements: vi.fn().mockResolvedValue([]),
}))

vi.mock('@/lib/mothership/request/lifecycle/headless', () => mothershipHeadlessLifecycleMock)
Expand Down Expand Up @@ -417,8 +417,9 @@ describe('POST /api/v2/chat', () => {
workspaceId: 'workspace-1',
chatId: SERVER_ISSUED_CHAT_ID,
integrationCatalog: { mcpServerIds: [] },
mode: 'agent',
})
for (const legacy of ['messages', 'mode', 'isHosted', 'workspaceContext', 'userPermission']) {
for (const legacy of ['messages', 'isHosted', 'workspaceContext', 'userPermission']) {
expect(payload).not.toHaveProperty(legacy)
}
expect(options).toMatchObject({
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/app/api/v2/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
buildPersistedUserMessage,
} from '@/lib/mothership/chat/persisted-message'
import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/mothership/constants'
import { computeEntitlements } from '@/lib/mothership/entitlements'
import {
type CopilotEnvironmentContext,
createCopilotEnvironmentContext,
Expand Down Expand Up @@ -343,6 +344,8 @@ export const POST = withRouteHandler(
chatId,
messageId,
integrationCatalog: { mcpServerIds: [] },
mode: 'agent',
entitlements: await computeEntitlements({ principal, workspaceId }),
...(effort ? { effort } : {}),
}

Expand Down
27 changes: 27 additions & 0 deletions apps/sim/app/api/workspaces/[id]/tests/[name]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { getWorkflowTestContract } from '@/lib/api/contracts/workflow-tests'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { workflowTestOperations } from '@/lib/workflow-tests/application/operations'
import { getWorkflowTestDetail } from '@/lib/workflow-tests/application/tests'

export const GET = defineInternalJsonRoute({
contract: getWorkflowTestContract,
auth: internalSessionAuth,
operation: workflowTestOperations.read,
rateLimit: internalRateLimits.user({
bucketName: 'workflow-tests-read',
// A running test polls these every 3s from each open page and chat tab.
config: { maxTokens: 240, refillRate: 120, refillIntervalMs: 60_000 },
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, query }) => ({
workspaceId: params.id,
name: params.name,
version: query.version,
}),
useCase: getWorkflowTestDetail,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { getWorkflowTestRunContract } from '@/lib/api/contracts/workflow-tests'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { workflowTestOperations } from '@/lib/workflow-tests/application/operations'
import { getWorkflowTestRunDetail } from '@/lib/workflow-tests/application/tests'

export const GET = defineInternalJsonRoute({
contract: getWorkflowTestRunContract,
auth: internalSessionAuth,
operation: workflowTestOperations.read,
rateLimit: internalRateLimits.user({
bucketName: 'workflow-tests-read',
// A running test polls these every 3s from each open page and chat tab.
config: { maxTokens: 240, refillRate: 120, refillIntervalMs: 60_000 },
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({
workspaceId: params.id,
name: params.name,
runId: params.runId,
}),
useCase: getWorkflowTestRunDetail,
})
23 changes: 23 additions & 0 deletions apps/sim/app/api/workspaces/[id]/tests/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { listWorkflowTestsContract } from '@/lib/api/contracts/workflow-tests'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { workflowTestOperations } from '@/lib/workflow-tests/application/operations'
import { listWorkflowTests } from '@/lib/workflow-tests/application/tests'

export const GET = defineInternalJsonRoute({
contract: listWorkflowTestsContract,
auth: internalSessionAuth,
operation: workflowTestOperations.read,
rateLimit: internalRateLimits.user({
bucketName: 'workflow-tests-read',
// A running test polls these every 3s from each open page and chat tab.
config: { maxTokens: 240, refillRate: 120, refillIntervalMs: 60_000 },
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, query }) => ({ workspaceId: params.id, version: query.version }),
useCase: listWorkflowTests,
})
19 changes: 19 additions & 0 deletions apps/sim/app/api/workspaces/[id]/tests/run/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { runWorkflowTestsContract } from '@/lib/api/contracts/workflow-tests'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { workflowTestOperations } from '@/lib/workflow-tests/application/operations'
import { startWorkflowTestRuns } from '@/lib/workflow-tests/application/run-tests'

export const POST = defineInternalJsonRoute({
contract: runWorkflowTestsContract,
auth: internalSessionAuth,
operation: workflowTestOperations.run,
rateLimit: internalRateLimits.user({ bucketName: 'workflow-tests' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }),
useCase: startWorkflowTestRuns,
})
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ async function render(
<FeatureFlagsProvider
flags={{
dashboards: false,
'workflow-tests': false,
'mothership-model-selector': mocks.advanced,
'mothership-plan-mode': mocks.plan,
}}
Expand Down Expand Up @@ -309,6 +310,7 @@ it.each([
<FeatureFlagsProvider
flags={{
dashboards: false,
'workflow-tests': false,
'mothership-model-selector': false,
'mothership-plan-mode': planEnabled,
}}
Expand Down Expand Up @@ -403,6 +405,7 @@ it('keeps restored queued skills scoped when replacing a draft', async () => {
<FeatureFlagsProvider
flags={{
dashboards: false,
'workflow-tests': false,
'mothership-model-selector': mocks.advanced,
'mothership-plan-mode': mocks.plan,
}}
Expand Down
26 changes: 15 additions & 11 deletions apps/sim/app/o/[organizationId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied'
Expand Down Expand Up @@ -55,24 +56,27 @@ export default async function OrganizationLayout({
if (!context.mothershipAvailable && !context.searchAccess.memberScoped)
redirect(WORKSPACE_SETTINGS_PATH)

const [, modelSelectorEnabled, planModeEnabled, dashboardsEnabled] = await Promise.all([
prefetchOrganizationSidebar(
queryClient,
organizationId,
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
getActiveOrganizationId(session)
),
isMothershipModelSelectorEnabled(),
isPlanModeEnabled(),
isDashboardsEnabled(organizationId),
])
const [, modelSelectorEnabled, planModeEnabled, dashboardsEnabled, workflowTestsEnabled] =
await Promise.all([
prefetchOrganizationSidebar(
queryClient,
organizationId,
{ kind: 'session', userId: session.user.id, sessionId: session.session.id },
getActiveOrganizationId(session)
),
isMothershipModelSelectorEnabled(),
isPlanModeEnabled(),
isDashboardsEnabled(organizationId),
isWorkflowTestsEnabled(organizationId),
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<FeatureFlagsProvider
flags={{
dashboards: dashboardsEnabled,
'workflow-tests': workflowTestsEnabled,
'mothership-model-selector': modelSelectorEnabled,
'mothership-plan-mode': planModeEnabled,
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Music } from '@sim/emcn/icons'
import dynamic from 'next/dynamic'
import type { FileDownloadSource } from '@/lib/uploads/client/download'
Expand Down Expand Up @@ -120,6 +120,12 @@ interface FileViewerProps {
status: 'idle' | 'saving' | 'saved' | 'error',
retry?: () => Promise<void>
) => void
/** Receives why a save was refused; text files only. */
onSaveError?: (error: unknown) => void
/** Replaces the file's own preview pane, for an owner with a richer view of the file. */
preview?: ReactNode
/** Which side of a split the source takes; `end` puts it after the preview. */
sourceSide?: 'start' | 'end'
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
downloadSourceRef?: React.MutableRefObject<FileDownloadSource | null>
discardRef?: React.MutableRefObject<(() => void) | null>
Expand Down Expand Up @@ -175,6 +181,9 @@ function FileViewerContent({
autoFocus,
onDirtyChange,
onSaveStatusChange,
onSaveError,
preview,
sourceSide,
saveRef,
downloadSourceRef,
discardRef,
Expand Down Expand Up @@ -253,6 +262,9 @@ function FileViewerContent({
autoFocus={autoFocus}
onDirtyChange={onDirtyChange}
onSaveStatusChange={onSaveStatusChange}
onSaveError={onSaveError}
preview={preview}
sourceSide={sourceSide}
saveRef={saveRef}
discardRef={discardRef}
streamingContent={streamingContent}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
memo,
type ClipboardEvent as ReactClipboardEvent,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useId,
Expand Down Expand Up @@ -405,6 +406,9 @@ interface TextEditorProps {
status: 'idle' | 'saving' | 'saved' | 'error',
retry?: () => Promise<void>
) => void
onSaveError?: (error: unknown) => void
preview?: ReactNode
sourceSide?: 'start' | 'end'
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
downloadSourceRef?: React.MutableRefObject<FileDownloadSource | null>
discardRef?: React.MutableRefObject<(() => void) | null>
Expand All @@ -422,6 +426,9 @@ export const TextEditor = memo(function TextEditor({
autoFocus,
onDirtyChange,
onSaveStatusChange,
onSaveError,
preview,
sourceSide = 'start',
saveRef,
downloadSourceRef,
discardRef,
Expand Down Expand Up @@ -503,6 +510,7 @@ export const TextEditor = memo(function TextEditor({
isAgentEditing,
onDirtyChange,
onSaveStatusChange,
onSaveError,
saveRef,
discardRef,
})
Expand Down Expand Up @@ -608,7 +616,8 @@ export const TextEditor = memo(function TextEditor({
if (!container) return
const rect = container.getBoundingClientRect()
const isRtl = getComputedStyle(container).direction === 'rtl'
const sourceWidth = isRtl ? rect.right - e.clientX : e.clientX - rect.left
const fromRight = isRtl !== (sourceSide === 'end')
const sourceWidth = fromRight ? rect.right - e.clientX : e.clientX - rect.left
const pct = (sourceWidth / rect.width) * 100
setSplitPct(Math.min(SPLIT_MAX_PCT, Math.max(SPLIT_MIN_PCT, pct)))
}
Expand All @@ -626,7 +635,7 @@ export const TextEditor = memo(function TextEditor({
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing])
}, [isResizing, sourceSide])

const handleSplitKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const key = readSeparatorKey(event)
Expand All @@ -639,7 +648,8 @@ export const TextEditor = memo(function TextEditor({
}
const container = containerRef.current
const isRtl = container !== null && getComputedStyle(container).direction === 'rtl'
const delta = (key === 'left' ? -1 : 1) * (isRtl ? -1 : 1) * SPLIT_KEYBOARD_STEP_PCT
const fromRight = isRtl !== (sourceSide === 'end')
const delta = (key === 'left' ? -1 : 1) * (fromRight ? -1 : 1) * SPLIT_KEYBOARD_STEP_PCT
setSplitPct((current) => Math.min(SPLIT_MAX_PCT, Math.max(SPLIT_MIN_PCT, current + delta)))
}

Expand Down Expand Up @@ -789,7 +799,10 @@ export const TextEditor = memo(function TextEditor({
<div
ref={containerRef}
data-find-tooltip-fix
className='relative flex flex-1 overflow-hidden'
className={cn(
'relative flex flex-1 overflow-hidden',
sourceSide === 'end' && 'flex-row-reverse'
)}
>
<style>{FIND_TOOLTIP_FIX_CSS}</style>
{showEditor && (
Expand Down Expand Up @@ -847,16 +860,18 @@ export const TextEditor = memo(function TextEditor({
isResizing && 'pointer-events-none'
)}
>
<PreviewPanel
key={previewContextKey ? `${file.id}:${previewContextKey}` : file.id}
content={content}
mimeType={file.type}
filename={file.name}
workspaceId={workspaceId}
fileId={file.id}
fileKey={file.key}
isStreaming={isStreaming}
/>
{preview ?? (
<PreviewPanel
key={previewContextKey ? `${file.id}:${previewContextKey}` : file.id}
content={content}
mimeType={file.type}
filename={file.name}
workspaceId={workspaceId}
fileId={file.id}
fileKey={file.key}
isStreaming={isStreaming}
/>
)}
</div>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ interface UseEditableFileContentOptions {
onDirtyChange?: (isDirty: boolean) => void
/** `retry` is this instance's own `saveImmediately`, passed alongside an `'error'` status so a caller-side retry never depends on a shared, remount-able ref. */
onSaveStatusChange?: (status: SaveStatus, retry?: () => Promise<void>) => void
/** Receives why a save was refused, for callers whose server checks the content itself. */
onSaveError?: (error: unknown) => void
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>
/** Bridges an imperative "discard the current draft" command up to the caller, mirroring `saveRef`. */
discardRef?: React.MutableRefObject<(() => void) | null>
Expand Down Expand Up @@ -165,13 +167,15 @@ export function useEditableFileContent({
isAgentEditing,
onDirtyChange,
onSaveStatusChange,
onSaveError,
saveRef,
discardRef,
normalizeBaseline,
canAutosave = true,
}: UseEditableFileContentOptions): EditableFileContent {
const onDirtyChangeRef = useRef(onDirtyChange)
const onSaveStatusChangeRef = useRef(onSaveStatusChange)
const onSaveErrorRef = useRef(onSaveError)

/**
* Mirrors the reducer's `reconciling` phase (assigned below the reducer hook; read here through a
Expand Down Expand Up @@ -293,6 +297,7 @@ export function useEditableFileContent({
everStreamedRef.current = everStreamed
onDirtyChangeRef.current = onDirtyChange
onSaveStatusChangeRef.current = onSaveStatusChange
onSaveErrorRef.current = onSaveError
updateContentRef.current = updateContent
streamActiveRef.current = isAgentStreamActive
contentRef.current = content
Expand Down Expand Up @@ -340,6 +345,7 @@ export function useEditableFileContent({
conflictRef.current = true
markConflict()
}
onSaveErrorRef.current?.(error)
throw error
}
},
Expand Down
Loading
Loading