From f5976cca326c1ebfdceafad0460255fd7aba75dc Mon Sep 17 00:00:00 2001 From: Eytan Singher Date: Mon, 9 Feb 2026 00:10:51 +0200 Subject: [PATCH] fix(web): use prompt_async endpoint to avoid timeout over VPN/tunnel The web UI sent prompts via POST /session/{id}/message which holds the connection open until the agent finishes. Over Tailscale/VPN the idle connection gets killed, showing 'Failed to send prompt' even though the server processed it fine. Switch to the existing POST /session/{id}/prompt_async endpoint that returns 204 immediately. Response data already arrives through SSE. Closes #12453 --- packages/app/e2e/prompt/prompt-async.spec.ts | 43 +++++++++++++++++++ .../app/src/components/prompt-input/submit.ts | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/prompt/prompt-async.spec.ts diff --git a/packages/app/e2e/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts new file mode 100644 index 000000000000..ce9b1a7a3bb1 --- /dev/null +++ b/packages/app/e2e/prompt/prompt-async.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { sessionIDFromUrl } from "../actions" + +// Regression test for Issue #12453: the synchronous POST /message endpoint holds +// the connection open while the agent works, causing "Failed to fetch" over +// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately. +test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => { + test.setTimeout(120_000) + + // Simulate Tailscale/VPN killing the long-lived sync connection + await page.route("**/session/*/message", (route) => route.abort("connectionfailed")) + + await gotoSession() + + const token = `E2E_ASYNC_${Date.now()}` + await page.locator(promptSelector).click() + await page.keyboard.type(`Reply with exactly: ${token}`) + await page.keyboard.press("Enter") + + await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) + const sessionID = sessionIDFromUrl(page.url())! + + try { + // Agent response arrives via SSE despite sync endpoint being dead + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) + return messages + .filter((m) => m.info.role === "assistant") + .flatMap((m) => m.parts) + .filter((p) => p.type === "text") + .map((p) => p.text) + .join("\n") + }, + { timeout: 90_000 }, + ) + .toContain(token) + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } +}) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index a96bdcbad5b2..3de57a769344 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -385,7 +385,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const send = async () => { const ok = await waitForWorktree() if (!ok) return - await client.session.prompt({ + await client.session.promptAsync({ sessionID: session.id, agent, model,