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({