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..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,6 +354,23 @@ describe('Home', () => { await waitFor(() => expect(onTaskStarted).toHaveBeenCalledOnce()); }); + it('starts an idempotent empty Session from the existing send button', async () => { + submittedPromptText = ''; + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Submit prompt' })); + + 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/NewTaskForm.tsx b/apps/web/src/components/tasks/NewTaskForm.tsx index 15a94abb9..f8df48dc9 100644 --- a/apps/web/src/components/tasks/NewTaskForm.tsx +++ b/apps/web/src/components/tasks/NewTaskForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useEffect, type Ref } from 'react'; +import { useState, useCallback, useEffect, useRef, type Ref } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; @@ -62,6 +62,7 @@ export function NewTaskForm({ const [selectedReasoningEffort, setSelectedReasoningEffort] = useState< ReasoningEffort | null | undefined >(undefined); + const emptySessionConversationIdRef = useRef(null); useEffect(() => setPromptText(promptParam), [promptParam]); useEffect(() => setSelectedModelOverrideId(modelParam), [modelParam]); @@ -75,6 +76,8 @@ export function NewTaskForm({ attachmentTexts?: string[]; model?: string | null; reasoningEffort?: ReasoningEffort | null; + conversationId?: string; + empty?: true; }): Promise => { // A second submit while the first is in flight would mint a second // session and orphan one of them. @@ -180,11 +183,21 @@ export function NewTaskForm({ }; if (!environmentIdParam) { - if ( + const isEmpty = !submission.description && !submission.images?.length && - !submission.attachmentTexts?.length - ) { + !submission.attachmentTexts?.length; + if (isEmpty) { + emptySessionConversationIdRef.current ??= crypto.randomUUID(); + await startFastSession({ + text: '', + conversationId: emptySessionConversationIdRef.current, + empty: true, + model: selectedModelOverrideId, + ...(selectedReasoningEffort !== undefined + ? { reasoningEffort: selectedReasoningEffort } + : {}), + }); return; } await startFastSession({ 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( {}} />); 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({