From 47969a8c3b287702f48930ffa4a5b8e67b1715fc Mon Sep 17 00:00:00 2001 From: DIodide Date: Mon, 31 Aug 2026 16:57:29 -0400 Subject: [PATCH] Send instantly and in order: open MCP connections in beforeTurn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the hibernation fix, send() awaited a setup(connect) round-trip (DO wake + engine MCP connect, 1-3s) before sendMessage — so a sent message vanished from the composer with nothing echoed on screen, and two quick sends could invert: the first paid the slow connect while the second found connections open and overtook it on the wire. Connections now open server-side in Think's beforeTurn (which returns the freshly connected MCP tools to merge into the turn, since Think assembles its automatic toolset before the hook while nothing is connected). The client sends immediately; sends are additionally serialized through a promise chain so ordering can never invert, with the first-message settings push bounded at 4s so it can't swallow a message. Retry no longer needs a pre-connect either, and plain settings pushes on chats skip Google entirely (the consent flow is desk-only). Claude-Session: https://claude.ai/code/session_01MKLJUWk6biNAKXupHTTWn5 --- app/src/client/pages/ChatPage.tsx | 31 ++++++++++++++++++----------- app/src/server/pi.ts | 33 ++++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/app/src/client/pages/ChatPage.tsx b/app/src/client/pages/ChatPage.tsx index f394495..90e0ebc 100644 --- a/app/src/client/pages/ChatPage.tsx +++ b/app/src/client/pages/ChatPage.tsx @@ -82,22 +82,25 @@ export function ChatPage({ }); /** - * Push settings to the agent. With `connect`, it also opens the MCP - * connections a turn needs — connections are held only while work runs - * (an idle Durable Object with live MCP clients never hibernates). + * Push settings to the agent (fast — MCP connections open server-side in + * beforeTurn, not here). */ - async function ensureSetup(connect = false) { - if (!connect && appliedRef.current === settingsHash) return; + async function ensureSetup() { + if (appliedRef.current === settingsHash) return; appliedRef.current = settingsHash; try { await agent.ready; - await agent.call("setup", [settings, { connect }]); + await agent.call("setup", [settings]); } catch (err) { console.warn("PI setup failed", err); appliedRef.current = null; } } + // Sends run strictly in click order: a slow settings push for one message + // must never let a later message overtake it on the wire. + const sendChain = useRef(Promise.resolve()); + // Apply changed settings to a conversation that's already underway. useEffect(() => { if (startedRef.current || messages.length > 0) void ensureSetup(); @@ -109,7 +112,7 @@ export function ChatPage({ if (el) el.scrollTop = el.scrollHeight; }, [messages, status]); - async function send(text: string) { + function send(text: string) { startedRef.current = true; if (isDraft) navigate(`/chat/${chatId}`, true); upsertChat(identity.netid, { @@ -117,9 +120,15 @@ export function ChatPage({ title: firstTitle(messages) ?? text.slice(0, 48), at: Date.now(), }); - // Open this turn's MCP connections (released again when the turn ends). - await ensureSetup(true); - void sendMessage({ text }); + sendChain.current = sendChain.current.then(async () => { + // The first message of a chat needs settings on the server; bound the + // wait so a slow push can never swallow a message silently. + await Promise.race([ + ensureSetup(), + new Promise((resolve) => setTimeout(resolve, 4000)), + ]); + void sendMessage({ text }); + }); } /** Copy history up to `endIndex` (exclusive) into a fresh chat. */ @@ -237,7 +246,7 @@ export function ChatPage({ } onRegenerate={ m.role === "assistant" && m.id === lastAssistantId - ? () => void ensureSetup(true).then(() => regenerate()) + ? () => void regenerate() : undefined } /> diff --git a/app/src/server/pi.ts b/app/src/server/pi.ts index 4eed388..4f473d2 100644 --- a/app/src/server/pi.ts +++ b/app/src/server/pi.ts @@ -155,9 +155,10 @@ export class Pi extends Think { const connectedIds = new Set(Object.keys(this.getMcpServers().servers)); for (const app of PI_APPS) { if (!enabled.has(app.key) || connectedIds.has(app.key)) continue; - // Engine connections are only opened right before a turn (see - // releaseIdleMcp for why); Google is handled here for the consent flow. - if (app.key !== "gcal" && !opts.connect) continue; + // Engine connections open right before a turn (see releaseIdleMcp for + // why); in a plain settings push only the desk's Google consent flow + // needs any connection work. + if (!opts.connect && !(app.key === "gcal" && this.isDesk())) continue; try { if (app.key === "gcal") { if ( @@ -253,6 +254,32 @@ export class Pi extends Think { } } + /** + * Open this turn's MCP connections on the server, so the client can fire + * a message instantly instead of awaiting a connect round-trip first + * (which both delayed the echo of sent messages and let quick successive + * sends overtake each other). Think assembles its automatic MCP toolset + * before this hook runs — while nothing is connected — so the freshly + * connected tools are returned here to be merged into the turn. + */ + override async beforeTurn( + ctx: Parameters[0] + ): Promise extends infer R ? Awaited : never> { + const inherited = await super.beforeTurn(ctx); + const settings = this.getConfig(); + if (!settings) return inherited ?? undefined; + try { + await this.setup(settings, { connect: true }); + } catch (err) { + console.warn("beforeTurn connect failed", err); + } + const tools = this.mcp.getAITools(); + return { + ...(inherited ?? {}), + tools: { ...(inherited?.tools ?? {}), ...tools }, + }; + } + override async onStart(props?: Record) { await super.onStart(props); await this.releaseIdleMcp();