From 24c24f4300e2f9dde068d32405f2ecb3436f29f7 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:46:11 +0000 Subject: [PATCH 1/2] feat: add empty Fast Session launch --- .../(authenticated)/home/Home.client.test.tsx | 18 ++++++ .../FastSessionTranscript.client.test.tsx | 26 +++++++++ .../[sessionId]/FastSessionTranscript.tsx | 14 ++++- .../sessions/[sessionId]/page.test.tsx | 57 +++++++++++++++++++ .../(sandbox)/sessions/[sessionId]/page.tsx | 3 + .../tasks/NewTaskDialog.client.test.tsx | 7 +++ .../src/components/tasks/NewTaskDialog.tsx | 1 + apps/web/src/components/tasks/NewTaskForm.tsx | 56 +++++++++++++++--- .../hooks/task-runs/useStartFastSession.ts | 4 ++ .../trpc/commands/fast-sessions/index.test.ts | 36 ++++++++++++ .../src/trpc/commands/fast-sessions/index.ts | 12 ++++ .../trpc/commands/fast-sessions/input.test.ts | 25 ++++++++ .../src/trpc/commands/fast-sessions/input.ts | 24 +++++++- 13 files changed, 270 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index d78f50347..8d5a3b18f 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -352,6 +352,24 @@ describe('Home', () => { await waitFor(() => expect(onTaskStarted).toHaveBeenCalledOnce()); }); + it('starts an idempotent empty Session from the explicit dialog action', async () => { + render(); + + fireEvent.click( + screen.getByRole('button', { name: 'Start empty session' }), + ); + + await waitFor(() => { + expect(mockStartFastSession).toHaveBeenCalledWith({ + text: '', + conversationId: expect.any(String), + empty: true, + model: undefined, + }); + }); + expect(mockPush).toHaveBeenCalledWith('/sessions/fast-session-1'); + }); + it('starts a Fast session with an image-only prompt', async () => { mockPreparePromptAttachments.mockResolvedValueOnce({ text: '', diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index a964886c7..8df2d8032 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -795,6 +795,32 @@ describe('FastSessionTranscript', () => { expect(screen.getByText('Thinking')).toBeInTheDocument(); }); + it('renders an empty persisted Session idle and accepts its first message', async () => { + replyMutate.mockResolvedValue({ success: true }); + render( + , + ); + + expect(screen.queryByText('Thinking')).not.toBeInTheDocument(); + const input = screen.getByPlaceholderText('Message agent'); + fireEvent.change(input, { target: { value: 'First message' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => + expect(replyMutate).toHaveBeenCalledWith({ + sessionId: 'session-1', + text: 'First message', + model: null, + reasoningEffort: null, + }), + ); + }); + it('waits for the first visible assistant message before showing timeline extras', () => { render( (null); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx index 28038d750..54c91113a 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -250,6 +250,63 @@ describe('Session detail page', () => { ); }); + it('renders a manually created empty Session without a pending kickoff', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getSessionByIdCommandMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000004', + title: 'New session', + ownerName: 'User', + ownerEmail: 'user@example.com', + ownerImageUrl: null, + sourceSurface: 'web', + sourceTrigger: 'manual', + fastConversationId: '6a1f8f1e-0000-4000-8000-000000000005', + directInferenceCostMicroUsd: 0, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'ready', + tasks: [], + artifacts: [], + }); + getFastSessionByIdMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000005', + userId: 'user-1', + ownerName: 'User', + ownerEmail: 'user@example.com', + ownerImageUrl: null, + title: null, + surface: 'web', + model: null, + reasoningEffort: null, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + messages: [], + hasOlderMessages: false, + }); + + renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000004', + }), + }), + ); + + expect(transcriptMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000005', + initialMessages: [], + initialResponsePending: false, + canReply: true, + }), + undefined, + ); + }); + it('hydrates a direct Session route without seeding the response lease', async () => { authorizeMock.mockResolvedValue({ success: true, diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index d03a7f5b1..206ac0a00 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -158,6 +158,9 @@ export default async function SessionDetailPage({ hasOlderMessages={session.hasOlderMessages} canReply initialTitle={unifiedSession.title} + initialResponsePending={ + unifiedSession.sourceTrigger !== 'manual' + } fallbackTitle={unifiedSession.title} sessionModel={session.model} sessionReasoningEffort={session.reasoningEffort} diff --git a/apps/web/src/components/tasks/NewTaskDialog.client.test.tsx b/apps/web/src/components/tasks/NewTaskDialog.client.test.tsx index e276a0f14..30364d4fc 100644 --- a/apps/web/src/components/tasks/NewTaskDialog.client.test.tsx +++ b/apps/web/src/components/tasks/NewTaskDialog.client.test.tsx @@ -7,14 +7,17 @@ const { onOpenChangeMock } = vi.hoisted(() => ({ vi.mock('./NewTaskForm', () => ({ NewTaskForm: ({ animate, + allowEmptySession, onTaskStarted, }: { animate?: boolean; + allowEmptySession?: boolean; onTaskStarted: () => void; }) => ( + + + ) : null} + } /> diff --git a/apps/web/src/hooks/task-runs/useStartFastSession.ts b/apps/web/src/hooks/task-runs/useStartFastSession.ts index de86c45c9..f98184362 100644 --- a/apps/web/src/hooks/task-runs/useStartFastSession.ts +++ b/apps/web/src/hooks/task-runs/useStartFastSession.ts @@ -36,6 +36,10 @@ type StartFastSessionVariables = { attachmentTexts?: string[]; model?: string | null; reasoningEffort?: ReasoningEffort | null; + /** Stable identity used to make a start retry idempotent. */ + conversationId?: string; + /** Persist the Session without scheduling an initial Fast turn. */ + empty?: true; /** Launch into a chosen workspace without a Fast decision. */ pinnedLaunch?: { launchId: string; diff --git a/apps/web/src/trpc/commands/fast-sessions/index.test.ts b/apps/web/src/trpc/commands/fast-sessions/index.test.ts index ab959e372..11215ba37 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.test.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.test.ts @@ -250,6 +250,9 @@ describe('startFastSessionCommand', () => { where: () => ({ limit: mocks.dbSelectLimit }), }); mocks.dbSelectLimit.mockResolvedValue([]); + mocks.dbUpdate.mockReturnValue({ set: mocks.dbSet }); + mocks.dbSet.mockReturnValue({ where: mocks.dbWhere }); + mocks.dbWhere.mockResolvedValue(undefined); }); it('recovers an idempotent Session without scheduling its first turn twice', async () => { @@ -299,6 +302,39 @@ describe('startFastSessionCommand', () => { expect(mocks.after).toHaveBeenCalledOnce(); }); + it('idempotently creates an empty Session without scheduling a Fast turn', async () => { + const input = { + text: '', + empty: true as const, + conversationId: '11111111-1111-4111-8111-111111111111', + }; + + await expect(startFastSessionCommand(auth, input)).resolves.toEqual({ + sessionId: 'unified-session-1', + fastConversationId: 'fast-session-1', + }); + mocks.getOrCreateSession.mockResolvedValueOnce({ + id: 'fast-session-1', + created: false, + }); + await expect(startFastSessionCommand(auth, input)).resolves.toEqual({ + sessionId: 'unified-session-1', + fastConversationId: 'fast-session-1', + }); + + expect(mocks.getOrCreateSession).toHaveBeenCalledTimes(2); + expect(mocks.getOrCreateSession).toHaveBeenLastCalledWith({ + userId: 'user-1', + conversation: { + surface: 'web', + workspaceId: 'user-1', + conversationId: input.conversationId, + }, + }); + expect(mocks.dbSet).toHaveBeenCalledWith({ sourceTrigger: 'manual' }); + expect(mocks.after).not.toHaveBeenCalled(); + }); + it('lets the initial Fast Session turn create a Session-owned artifact', async () => { let scheduled: (() => Promise) | undefined; mocks.after.mockImplementation((callback) => { diff --git a/apps/web/src/trpc/commands/fast-sessions/index.ts b/apps/web/src/trpc/commands/fast-sessions/index.ts index e1ec6a253..e8abdaa9b 100644 --- a/apps/web/src/trpc/commands/fast-sessions/index.ts +++ b/apps/web/src/trpc/commands/fast-sessions/index.ts @@ -380,6 +380,7 @@ export async function startFastSessionCommand( model?: string | null; reasoningEffort?: ReasoningEffort | null; conversationId?: string; + empty?: true; pinnedLaunch?: PinnedFastSessionLaunchInput; }, ): Promise<{ @@ -414,6 +415,17 @@ export async function startFastSessionCommand( }); const unifiedSession = await ensureSessionForFastConversation(db, session.id); + if (input.empty) { + await db + .update(sessions) + .set({ sourceTrigger: 'manual' }) + .where(eq(sessions.id, unifiedSession.id)); + return { + sessionId: unifiedSession.id, + fastConversationId: session.id, + }; + } + const kickoffTurnId = input.conversationId ? `web-kickoff:${session.id}` : undefined; diff --git a/apps/web/src/trpc/commands/fast-sessions/input.test.ts b/apps/web/src/trpc/commands/fast-sessions/input.test.ts index 09e555dc4..e06169b83 100644 --- a/apps/web/src/trpc/commands/fast-sessions/input.test.ts +++ b/apps/web/src/trpc/commands/fast-sessions/input.test.ts @@ -66,6 +66,31 @@ describe('Fast session input schemas', () => { ).toThrow('Text or at least one attachment is required'); }); + it('accepts an explicit idempotent empty Session start', () => { + expect( + startFastSessionInputSchema.parse({ + text: '', + empty: true, + conversationId: '11111111-1111-4111-8111-111111111111', + }), + ).toEqual({ + text: '', + empty: true, + conversationId: '11111111-1111-4111-8111-111111111111', + }); + + expect(() => + startFastSessionInputSchema.parse({ text: '', empty: true }), + ).toThrow('Empty session starts require a conversation ID'); + expect(() => + startFastSessionInputSchema.parse({ + text: 'Unexpected prompt', + empty: true, + conversationId: '11111111-1111-4111-8111-111111111111', + }), + ).toThrow('An empty session cannot include message content'); + }); + it('rejects image values the Fast service cannot use', () => { expect(() => startFastSessionInputSchema.parse({ diff --git a/apps/web/src/trpc/commands/fast-sessions/input.ts b/apps/web/src/trpc/commands/fast-sessions/input.ts index d40183ad9..7ad8252b1 100644 --- a/apps/web/src/trpc/commands/fast-sessions/input.ts +++ b/apps/web/src/trpc/commands/fast-sessions/input.ts @@ -56,6 +56,7 @@ function requireFastSessionContent( images?: string[]; attachmentTexts?: string[]; pinnedLaunch?: unknown; + empty?: true; }, ctx: z.RefinementCtx, ): void { @@ -63,6 +64,16 @@ function requireFastSessionContent( if (input.pinnedLaunch) { return; } + if (input.empty) { + if (input.text || input.images?.length || input.attachmentTexts?.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'An empty session cannot include message content', + path: ['empty'], + }); + } + return; + } if (!input.text && !input.images?.length && !input.attachmentTexts?.length) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -87,9 +98,20 @@ function requireFastSessionContent( export const startFastSessionInputSchema = z .object({ ...fastSessionMessageInputShape, + conversationId: z.string().uuid().optional(), + empty: z.literal(true).optional(), pinnedLaunch: pinnedFastSessionLaunchSchema.optional(), }) - .superRefine(requireFastSessionContent); + .superRefine((input, ctx) => { + requireFastSessionContent(input, ctx); + if (input.empty && !input.conversationId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Empty session starts require a conversation ID', + path: ['conversationId'], + }); + } + }); export const replyToFastSessionInputSchema = z .object({ From c63184f256ae1f4d30fbe2d45885acf48eb04e8d Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:11:01 +0000 Subject: [PATCH 2/2] fix: start empty Sessions from the composer --- .../(authenticated)/home/Home.client.test.tsx | 15 +++-- .../tasks/NewTaskDialog.client.test.tsx | 7 -- .../src/components/tasks/NewTaskDialog.tsx | 1 - apps/web/src/components/tasks/NewTaskForm.tsx | 67 ++++++------------- .../tasks/TaskPromptInput.client.test.tsx | 21 +++++- 5 files changed, 48 insertions(+), 63 deletions(-) diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index 8d5a3b18f..98fa178f9 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -18,6 +18,7 @@ let currentEnvironments: Array<{ id: string; name: string }> | undefined = [ let currentEnvironmentsPending = false; let capturedSubmitWithMetaKey: boolean | undefined; let capturedDefaultReasoningEffort: string | null | undefined; +let submittedPromptText = 'Test prompt'; const { mockPush, @@ -160,8 +161,8 @@ vi.mock('@/components/tasks', async () => { if (submitDisabledReason) { return; } - onPromptTextChange?.('Test prompt'); - const result = onSubmit({ text: 'Test prompt', files: [] }); + onPromptTextChange?.(submittedPromptText); + const result = onSubmit({ text: submittedPromptText, files: [] }); if (result instanceof Promise) { void result.catch(() => {}); @@ -231,6 +232,7 @@ describe('Home', () => { currentEnvironmentsPending = false; capturedSubmitWithMetaKey = undefined; capturedDefaultReasoningEffort = undefined; + submittedPromptText = 'Test prompt'; localStorage.clear(); vi.clearAllMocks(); @@ -352,12 +354,11 @@ describe('Home', () => { await waitFor(() => expect(onTaskStarted).toHaveBeenCalledOnce()); }); - it('starts an idempotent empty Session from the explicit dialog action', async () => { - render(); + it('starts an idempotent empty Session from the existing send button', async () => { + submittedPromptText = ''; + render(); - fireEvent.click( - screen.getByRole('button', { name: 'Start empty session' }), - ); + fireEvent.click(screen.getByRole('button', { name: 'Submit prompt' })); await waitFor(() => { expect(mockStartFastSession).toHaveBeenCalledWith({ diff --git a/apps/web/src/components/tasks/NewTaskDialog.client.test.tsx b/apps/web/src/components/tasks/NewTaskDialog.client.test.tsx index 30364d4fc..e276a0f14 100644 --- a/apps/web/src/components/tasks/NewTaskDialog.client.test.tsx +++ b/apps/web/src/components/tasks/NewTaskDialog.client.test.tsx @@ -7,17 +7,14 @@ const { onOpenChangeMock } = vi.hoisted(() => ({ vi.mock('./NewTaskForm', () => ({ NewTaskForm: ({ animate, - allowEmptySession, onTaskStarted, }: { animate?: boolean; - allowEmptySession?: boolean; onTaskStarted: () => void; }) => ( - - - ) : null} - + } /> diff --git a/apps/web/src/components/tasks/TaskPromptInput.client.test.tsx b/apps/web/src/components/tasks/TaskPromptInput.client.test.tsx index f84c92eac..5becaf764 100644 --- a/apps/web/src/components/tasks/TaskPromptInput.client.test.tsx +++ b/apps/web/src/components/tasks/TaskPromptInput.client.test.tsx @@ -26,12 +26,14 @@ function KeyboardPrompt({ onSubmit, isBusy = false, submitDisabledReason, + initialText = 'Fix the login bug', }: { - onSubmit: () => void; + onSubmit: (...args: unknown[]) => void; isBusy?: boolean; submitDisabledReason?: string; + initialText?: string; }) { - const [promptText, setPromptText] = useState('Fix the login bug'); + const [promptText, setPromptText] = useState(initialText); return ( { await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce()); }); + it('submits an empty composer through the existing button and Enter shortcut', async () => { + const onSubmit = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + fireEvent.keyDown(screen.getByRole('textbox'), { + key: 'Enter', + code: 'Enter', + }); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + expect(onSubmit.mock.calls[0]?.[0]).toEqual({ text: '', files: [] }); + expect(onSubmit.mock.calls[1]?.[0]).toEqual({ text: '', files: [] }); + }); + it('advertises the Enter shortcut in plain-Enter mode', async () => { render( {}} />);