From 7f09c7bfd6a43e5a26fc2fb3758f5be7f9d11b26 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 25 Oct 2026 14:22:28 +0200 Subject: [PATCH 1/3] feat(platform)!: createdAt ends in Z, and useChat knows when Base44 has a prompt or answer ChatItem.createdAt passed Base44's created_date through with no zone, so new Date() read it as local time, while a pending prompt's ended in Z. The hook now gives every timestamp its Z, in the pending prompt's shape. Send chat message and Submit tool-call input stay open until the turn ends, and there is no "accepted" response to resolve on. The hook now reads it from the live updates instead: Base44's copy of the prompt, the prompt in its queue (event or snapshot), or the question no longer waiting. send() and the answer actions resolve then, and a backend failure after that, such as a server timeout mid-turn, no longer sets error, drops the prompt or reopens the question. ChatBackend's docs now say to forward the request and return its result. BREAKING CHANGE: ChatItem.createdAt always ends in Z. Remove any Z you added. Co-Authored-By: Claude Opus 5.5 --- packages/platform/CHANGELOG.md | 12 +++ packages/platform/src/react/chat-items.ts | 29 ++++++- packages/platform/src/react/chat.types.ts | 18 ++-- packages/platform/src/react/useChat.ts | 87 ++++++++++++++----- .../tests/unit/react/chat-items.test.ts | 38 ++++++-- .../platform/tests/unit/react/useChat.test.ts | 85 ++++++++++++++++++ 6 files changed, 233 insertions(+), 36 deletions(-) diff --git a/packages/platform/CHANGELOG.md b/packages/platform/CHANGELOG.md index bc331411..ac0d0baf 100644 --- a/packages/platform/CHANGELOG.md +++ b/packages/platform/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +- **Breaking:** `ChatItem.createdAt` always ends in `Z` now. Base44's own timestamps used to come + through with no zone, so `new Date()` read them as local time. If you added a `Z` yourself, + remove it, or the date won't parse. +- `useChat` learns from the live updates when Base44 has a prompt or an answer: Base44's copy of + the prompt arrives, its queue holds the prompt, or the question stops waiting. `send()` and the + answer actions resolve then, and a failure after that, such as a timeout on your server while + the turn runs, no longer sets `error` or removes the prompt. Your `ChatBackend` can forward + Send chat message and Submit tool-call input and return their result, however long the turn + takes. + ## 0.5.0 The first release to build on. Versions 0.1.0 to 0.4.0 were previews and are deprecated on npm. diff --git a/packages/platform/src/react/chat-items.ts b/packages/platform/src/react/chat-items.ts index fc3d0da8..a39be532 100644 --- a/packages/platform/src/react/chat-items.ts +++ b/packages/platform/src/react/chat-items.ts @@ -54,7 +54,7 @@ export function toItems(sortedMessages: Message[], answeredToolCallIds: string[] id: message.id!, role: message.role === "user" ? "user" : "assistant", text: message.content ?? "", - createdAt: message.metadata?.created_date ?? "", + createdAt: toUtcTimestamp(message.metadata?.created_date), pending: false, steps: (message.tool_calls ?? []).map(toolCall => { const isAnswerable = message === lastReply && !answeredToolCallIds.includes(toolCall.id!); @@ -66,6 +66,17 @@ export function toItems(sortedMessages: Message[], answeredToolCallIds: string[] })); } +/** @internal Base44 writes UTC with no zone, which `new Date()` would read as local time. The + * timestamp comes out with its `Z`, in the shape a pending prompt has. Anything but an ISO date and + * time is left as it is: `new Date()` guesses at other shapes. */ +export function toUtcTimestamp(date: string | null | undefined): string { + if (!date) return ""; + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(date)) return date; + const hasZone = /(Z|[+-]\d{2}:?\d{2})$/i.test(date); + const parsed = new Date(hasZone ? date : `${date}Z`); + return Number.isNaN(parsed.getTime()) ? date : parsed.toISOString(); +} + // What a tool's display projection may carry; each tool declares only some of it. type ToolDisplay = { file_paths?: string[]; summary?: string; entity_name?: string; record_count?: number }; @@ -153,12 +164,14 @@ export function waitingToolCallIds(sortedMessages: Message[]): Set { ); } -/** @internal A prompt sent from this chat that Base44 has not confirmed, for the app it went to. */ +/** @internal A prompt sent from this chat that Base44 has not confirmed, for the app it went to. + * `queued` once Base44's queue holds it: Base44 has it, and it waits to run. */ export interface PendingPrompt { id: string; content: string; createdAt: string; appId: string; + queued: boolean; } /** @internal Prompts that arrived from Base44 as messages settle the pending ones with the same @@ -173,6 +186,18 @@ export function settlePendingPrompts(pendingPrompts: PendingPrompt[], appId: str return stillPending; } +/** @internal Base44's queue for the app marks the pending prompts it holds, one queued prompt per + * pending one with the same text. Text is the only link here too. */ +export function markQueuedPrompts(pendingPrompts: PendingPrompt[], appId: string, queuedPrompts: string[]): PendingPrompt[] { + const matched = new Set(); + for (const queuedPrompt of queuedPrompts) { + const match = pendingPrompts.find(prompt => !matched.has(prompt) && prompt.appId === appId && prompt.content.trim() === queuedPrompt.trim()); + if (match) matched.add(match); + } + if ([...matched].every(prompt => prompt.queued)) return pendingPrompts; + return pendingPrompts.map(prompt => (matched.has(prompt) && !prompt.queued ? { ...prompt, queued: true } : prompt)); +} + /** @internal Pending prompts as items, drawn after the messages Base44 has. */ export function pendingPromptItems(pendingPrompts: PendingPrompt[]): ChatItem[] { return pendingPrompts.map(prompt => ({ id: prompt.id, role: "user", text: prompt.content, createdAt: prompt.createdAt, steps: [], pending: true })); diff --git a/packages/platform/src/react/chat.types.ts b/packages/platform/src/react/chat.types.ts index 243a2f21..8a878237 100644 --- a/packages/platform/src/react/chat.types.ts +++ b/packages/platform/src/react/chat.types.ts @@ -37,8 +37,10 @@ export interface ToolCallAnswer { * `X-Request-ID` built from the tool call ID, so a retry resumes the turn only once. * * Base44 keeps the Send chat message and Submit tool-call input requests open until the AI - * finishes the turn. Resolve your call once Base44 accepts the request, not when it returns. The - * chat follows the rest live. A rejected promise from any call sets [`error`](#error). + * finishes the turn, which can take minutes. Forward the request and return its result: the hook + * learns from the live updates as soon as Base44 has the prompt or the answer, and a failure after + * that, such as a timeout on your server, doesn't set an error. A rejected promise from any call + * before then sets [`error`](#error). * * @example * ```typescript @@ -114,15 +116,16 @@ export interface ChatBackend { * * @param appId - ID of the app. * @param content - The prompt. - * @returns Promise resolving once Base44 accepts the prompt. + * @returns Promise resolving when Base44 answers the request, which can be when the turn ends. */ sendMessage(appId: string, content: string): Promise; /** - * Answers or declines a question the AI waits on. + * Answers or declines a question the AI waits on. If Base44 refuses the answer, reject the + * promise. The hook then sets [`error`](#error) and shows the question again. * * @param appId - ID of the app. * @param answer - The tool call and the answer to it. - * @returns Promise resolving once Base44 accepts the answer. + * @returns Promise resolving when Base44 answers the request, which can be when the turn ends. */ submitToolCallInput(appId: string, answer: ToolCallAnswer): Promise; } @@ -136,9 +139,8 @@ export interface ChatItem { /** The message text, as raw markdown. Show it before the message's steps. It's empty when the message only holds tool calls. */ text: string; /** - * When the message was created, as an ISO 8601 timestamp in UTC. A timestamp from Base44 has no `Z` - * or offset, so add a `Z` before parsing it with `new Date()`. A pending prompt has the browser's - * time, which already ends in `Z`. + * When the message was created, as an ISO 8601 timestamp in UTC that ends in `Z`, such as + * `2026-10-25T11:00:00.000Z`. `new Date(createdAt)` reads it correctly in any time zone. */ createdAt: string; /** One step per tool call, in order. */ diff --git a/packages/platform/src/react/useChat.ts b/packages/platform/src/react/useChat.ts index 098f7f69..5d481734 100644 --- a/packages/platform/src/react/useChat.ts +++ b/packages/platform/src/react/useChat.ts @@ -1,8 +1,8 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPlatformClient } from "../client/client.js"; -import type { Message, StatusObject } from "../client/modules/chat.events.generated.js"; -import { isWaitingForUser, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, sortMessages, toItems, unknownKindToolCalls, waitingToolCallIds, type PendingPrompt } from "./chat-items.js"; +import type { Message, QueueItem, StatusObject } from "../client/modules/chat.events.generated.js"; +import { isWaitingForUser, markQueuedPrompts, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, sortMessages, toItems, unknownKindToolCalls, waitingToolCallIds, type PendingPrompt } from "./chat-items.js"; import type { UseChatResult, ChatOptions, ChatConnection, ChatError, ChatStatus } from "./chat.types.js"; // Base44's public status carries no reason, so a failed build has one error. @@ -70,8 +70,15 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR // Tool calls declined for the user, and those whose decline failed and may try again. const declinedToolCalls = useRef(new Set()); const failedDeclines = useRef(new Set()); - // Tool calls answered from here whose submission ends when Base44 stops reporting them waiting. - const answersAwaitingBase44 = useRef(new Set()); + // Base44 keeps a send or an answer open until the turn ends, so the live updates say when Base44 + // has it: that ends the action, and a failure after it, such as a timeout on the way, is no + // refusal. Each entry holds what resolves the action. + // Tool calls answered from here, whose submission ends when Base44 stops reporting them waiting. + const answersAwaitingBase44 = useRef(new Map void>()); + // Prompts sent from here, by pending id, until Base44's copy settles them or its queue holds them. + const promptsAwaitingBase44 = useRef(new Map void; shown: boolean }>()); + // The tool calls Base44 reports waiting, as of the last render. + const waitingToolCalls = useRef(new Set()); // The app `create` made, shown while `appId` is null. Any change to `appId` forgets it. const [createdAppId, setCreatedAppId] = useState(null); @@ -146,6 +153,10 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR setPendingPrompts(prompts => settlePendingPrompts(prompts, followedAppId, arrivedPrompts)); } }; + const handleQueue = (queueItems: QueueItem[] | null | undefined) => { + const queuedPrompts = (queueItems ?? []).map(queueItem => queueItem.content ?? ""); + if (queuedPrompts.length) setPendingPrompts(prompts => markQueuedPrompts(prompts, followedAppId, queuedPrompts)); + }; // A snapshot replaces the conversation when it is the first, or follows a rewrite of main // (undo, restore, sync); after a plain rejoin the messages before its window stay. let nextSnapshotReplaces = true; @@ -189,6 +200,7 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR onSnapshot: snapshot => { // A snapshot holds old prompts too, so only an exact match settles one. handleIncomingMessages(snapshot.messages); + handleQueue(snapshot.queue?.items); const replaceMessages = nextSnapshotReplaces; nextSnapshotReplaces = false; setMessages(heldMessages => (replaceMessages ? snapshot.messages : mergeSnapshot(heldMessages, snapshot.messages))); @@ -210,7 +222,10 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR if (event.type === "conversation.changed" && event.data.branch_id == null) nextSnapshotReplaces = true; // A prompt sent while the AI is working waits in Base44's queue: Base44 has it, and it // stays pending until it runs. - if (event.type === "queue.updated" && event.data.branch_id == null) setSubmittedTo(null); + if (event.type === "queue.updated" && event.data.branch_id == null) { + setSubmittedTo(null); + handleQueue(event.data.items); + } }, // Only snapshot_unavailable leaves the subscription running. onError: error => ((error as { code?: string }).code === "snapshot_unavailable" ? reportConnectionError(error) : endLiveUpdates(error)), @@ -224,8 +239,8 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR }; }, [currentAppId]); - // Answers one tool call. Its id leaves the answered list again if the submit fails, so the - // question comes back. + // Answers one tool call. Its id leaves the answered list again if Base44 refuses the answer, so + // the question comes back. const submitAnswer = useCallback( (toolCallId: string, messageId: string, approve: boolean, extraUserInput?: Record) => { if (!currentAppId) return Promise.resolve(); @@ -233,14 +248,17 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR setActionError(null); setSubmittedTo(targetAppId); setAnsweredToolCallIds(ids => [...ids, toolCallId]); - answersAwaitingBase44.current.add(toolCallId); const answer = { toolCallId, messageId, approve, extraUserInput }; - return backendRef.current.submitToolCallInput(targetAppId, answer).catch((error: unknown) => { - answersAwaitingBase44.current.delete(toolCallId); - if (!stillFollowing(targetAppId)) return; - setAnsweredToolCallIds(ids => ids.filter(answeredId => answeredId !== toolCallId)); - setSubmittedTo(null); - reportActionError(error); + return new Promise(resolve => { + answersAwaitingBase44.current.set(toolCallId, resolve); + backendRef.current.submitToolCallInput(targetAppId, answer).then(resolve, (error: unknown) => { + resolve(); + if (!answersAwaitingBase44.current.delete(toolCallId)) return; + if (!stillFollowing(targetAppId)) return; + setAnsweredToolCallIds(ids => ids.filter(answeredId => answeredId !== toolCallId)); + setSubmittedTo(null); + reportActionError(error); + }); }); }, [currentAppId, reportActionError], @@ -268,6 +286,8 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR declinedToolCalls.current.add(declineKey); setAnsweredToolCallIds(ids => [...ids, toolCallId]); backendRef.current.submitToolCallInput(followedAppId, { toolCallId, messageId, approve: false }).catch((error: unknown) => { + // A call that no longer waits has the decline: the failure came after it. + if (!waitingToolCalls.current.has(toolCallId)) return; failedDeclines.current.add(declineKey); setAnsweredToolCallIds(ids => ids.filter(answeredId => answeredId !== toolCallId)); reportActionError(error); @@ -280,16 +300,30 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR // An answer's submission ends there too: Base44 has taken it once the call no longer waits. useEffect(() => { const stillWaiting = waitingToolCallIds(sortedMessages); + waitingToolCalls.current = stillWaiting; setAnsweredToolCallIds(ids => (ids.every(answeredId => stillWaiting.has(answeredId)) ? ids : ids.filter(answeredId => stillWaiting.has(answeredId)))); let answerTaken = false; - for (const toolCallId of answersAwaitingBase44.current) { + for (const [toolCallId, resolveAnswer] of answersAwaitingBase44.current) { if (stillWaiting.has(toolCallId)) continue; answersAwaitingBase44.current.delete(toolCallId); + resolveAnswer(); answerTaken = true; } if (answerTaken) setSubmittedTo(target => (target === followedAppIdRef.current ? null : target)); }, [sortedMessages]); + // A prompt's send ends once Base44 has it: its copy settled the pending prompt, or its queue holds + // it. A prompt not shown yet is still on its way to the list, not settled. + useEffect(() => { + for (const [promptId, sentPrompt] of promptsAwaitingBase44.current) { + const pendingPrompt = pendingPrompts.find(prompt => prompt.id === promptId); + if (pendingPrompt) sentPrompt.shown = true; + if (pendingPrompt ? !pendingPrompt.queued : !sentPrompt.shown) continue; + promptsAwaitingBase44.current.delete(promptId); + sentPrompt.resolve(); + } + }, [pendingPrompts]); + const submissionInFlight = (submittedTo === NEW_APP && !currentAppId) || (submittedTo !== null && submittedTo === currentAppId); let status: ChatStatus = "ready"; if (buildState === "error") status = "error"; @@ -298,7 +332,7 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR else if (submissionInFlight) status = "submitted"; const addPendingPrompt = useCallback((content: string, targetAppId: string) => { - const pendingPrompt = { id: `pending-${++pendingPromptCount.current}`, content, createdAt: new Date().toISOString(), appId: targetAppId }; + const pendingPrompt = { id: `pending-${++pendingPromptCount.current}`, content, createdAt: new Date().toISOString(), appId: targetAppId, queued: false }; setPendingPrompts(prompts => [...prompts, pendingPrompt]); return pendingPrompt; }, []); @@ -313,11 +347,22 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR setActionError(null); setSubmittedTo(targetAppId); const pendingPrompt = addPendingPrompt(prompt, targetAppId); - return backendRef.current.sendMessage(targetAppId, prompt).catch((error: unknown) => { - setPendingPrompts(prompts => prompts.filter(other => other !== pendingPrompt)); - if (!stillFollowing(targetAppId)) return; - setSubmittedTo(null); - reportActionError(error); + return new Promise(resolve => { + promptsAwaitingBase44.current.set(pendingPrompt.id, { resolve, shown: false }); + backendRef.current.sendMessage(targetAppId, prompt).then( + () => { + promptsAwaitingBase44.current.delete(pendingPrompt.id); + resolve(); + }, + (error: unknown) => { + resolve(); + if (!promptsAwaitingBase44.current.delete(pendingPrompt.id)) return; + setPendingPrompts(prompts => prompts.filter(other => other.id !== pendingPrompt.id)); + if (!stillFollowing(targetAppId)) return; + setSubmittedTo(null); + reportActionError(error); + }, + ); }); }, [currentAppId, reportActionError, addPendingPrompt], diff --git a/packages/platform/tests/unit/react/chat-items.test.ts b/packages/platform/tests/unit/react/chat-items.test.ts index dd5e9631..bceec107 100644 --- a/packages/platform/tests/unit/react/chat-items.test.ts +++ b/packages/platform/tests/unit/react/chat-items.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Message } from "../../../src/client/modules/chat.events.generated.js"; import type { ToolCall } from "../../../src/client/modules/chat.events.types.js"; -import { toQuestion, isWaitingForUser, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, SNAPSHOT_SIZE, sortMessages, stepStatus, toItems, unknownKindToolCalls, waitingToolCallIds } from "../../../src/react/chat-items.js"; +import { toQuestion, isWaitingForUser, markQueuedPrompts, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, SNAPSHOT_SIZE, sortMessages, stepStatus, toItems, toUtcTimestamp, unknownKindToolCalls, waitingToolCallIds } from "../../../src/react/chat-items.js"; const message = (id: string, created: string, extra: Partial = {}): Message => ({ id, role: "assistant", metadata: { created_date: created }, ...extra }); const waiting = (id: string, extra: Partial = {}): ToolCall => ({ id, name: "tool", status: "waiting_for_user_input", ...extra }); @@ -47,8 +47,8 @@ describe("items", () => { { id: "t3", name: "run", status: "error" }, { id: "t4", name: "run", status: "stopped" }, ]; - const [item] = toItems([message("m", "2026-10-14T10:00:00Z", { role: "user", content: "hi", tool_calls: tools })], [], vi.fn()); - expect(item).toMatchObject({ id: "m", role: "user", text: "hi", createdAt: "2026-10-14T10:00:00Z" }); + const [item] = toItems([message("m", "2026-10-14T10:00:00.123456", { role: "user", content: "hi", tool_calls: tools })], [], vi.fn()); + expect(item).toMatchObject({ id: "m", role: "user", text: "hi", createdAt: "2026-10-14T10:00:00.123Z" }); expect(item.steps[0]).toEqual({ id: "t1", tool: "write_file", files: ["src/App.jsx", "src/index.css"], label: "write_file src/App.jsx src/index.css", status: "done" }); expect(item.steps.map(s => [s.label, s.status])).toEqual([["write_file src/App.jsx src/index.css", "done"], ["run", "running"], ["run", "error"], ["run", "stopped"]]); expect(item.steps.every(s => !("question" in s))).toBe(true); @@ -162,8 +162,24 @@ describe("steps", () => { }); }); +describe("timestamps", () => { + test("Base44's zoneless UTC gets its Z, in the shape a pending prompt has", () => { + expect(toUtcTimestamp("2026-10-25T11:00:00")).toBe("2026-10-25T11:00:00.000Z"); + expect(toUtcTimestamp("2026-10-25T11:00:00.123456")).toBe("2026-10-25T11:00:00.123Z"); + expect(toUtcTimestamp(new Date(Date.UTC(2026, 9, 25, 11)).toISOString())).toBe("2026-10-25T11:00:00.000Z"); + }); + + test("a zone already there is kept, and what doesn't parse is left as it is", () => { + expect(toUtcTimestamp("2026-10-25T11:00:00Z")).toBe("2026-10-25T11:00:00.000Z"); + expect(toUtcTimestamp("2026-10-25T14:00:00+03:00")).toBe("2026-10-25T11:00:00.000Z"); + expect(toUtcTimestamp("1")).toBe("1"); + expect(toUtcTimestamp(undefined)).toBe(""); + expect(toUtcTimestamp(null)).toBe(""); + }); +}); + describe("pending prompts", () => { - const pending = (id: string, content: string, appId = "a") => ({ id, content, createdAt: "", appId }); + const pending = (id: string, content: string, appId = "a") => ({ id, content, createdAt: "", appId, queued: false }); test("an arrival settles the pending prompt with its text, of its own app, ignoring outer spaces", () => { const all = [pending("p1", "one"), pending("p2", "two"), pending("p3", "two", "b")]; @@ -175,8 +191,20 @@ describe("pending prompts", () => { expect(settlePendingPrompts([pending("p1", "one"), pending("p2", "two")], "a", ["someone else's"]).map(p => p.id)).toEqual(["p1", "p2"]); }); + test("the queue marks one pending prompt per queued prompt with its text, of its own app", () => { + const all = [pending("p1", "two"), pending("p2", "two"), pending("p3", "two", "b"), pending("p4", "one")]; + const marked = markQueuedPrompts(all, "a", [" two "]); + expect(marked.map(p => [p.id, p.queued])).toEqual([["p1", true], ["p2", false], ["p3", false], ["p4", false]]); + expect(markQueuedPrompts(marked, "a", ["two", "two"]).map(p => p.queued)).toEqual([true, true, false, false]); + }); + + test("a queue that marks nothing new leaves the same list", () => { + const marked = markQueuedPrompts([pending("p1", "one")], "a", ["one"]); + expect(markQueuedPrompts(marked, "a", ["one", "someone else's"])).toBe(marked); + }); + test("a pending prompt is a user item with no steps", () => { - expect(pendingPromptItems([{ id: "p1", content: "hi", createdAt: "t", appId: "a" }])).toEqual([ + expect(pendingPromptItems([{ id: "p1", content: "hi", createdAt: "t", appId: "a", queued: false }])).toEqual([ { id: "p1", role: "user", text: "hi", createdAt: "t", steps: [], pending: true }, ]); }); diff --git a/packages/platform/tests/unit/react/useChat.test.ts b/packages/platform/tests/unit/react/useChat.test.ts index 70739a31..fe482539 100644 --- a/packages/platform/tests/unit/react/useChat.test.ts +++ b/packages/platform/tests/unit/react/useChat.test.ts @@ -492,6 +492,57 @@ describe("pending prompts", () => { expect(latest.error).toMatchObject({ message: "Send message failed: 409" }); }); + test("send resolves once Base44's copy arrives, and a failure after that is no refusal", async () => { + const backend = makeBackend(); + const call = pending(); + backend.sendMessage.mockReturnValueOnce(call.promise); + await mount({ appId: app, backend }); + await snapshot([]); + let resolved = false; + let done!: Promise; + await act(async () => { done = latest.send("make it blue").then(() => { resolved = true; }); }); + expect(resolved).toBe(false); + await updated(message("u1", "1", { role: "user", content: "make it blue" })); + await settle(); + expect(resolved).toBe(true); + // The turn outlives the backend's own timeout. + await act(async () => { call.reject(new Error("Send message failed: 502")); await done; }); + await settle(); + expect(latest.error).toBeNull(); + expect(shown()).toEqual([["make it blue", false]]); + expect(latest.status).toBe("submitted"); + }); + + test("a prompt Base44 queued stays pending when its send fails afterwards", async () => { + const backend = makeBackend(); + const call = pending(); + backend.sendMessage.mockReturnValueOnce(call.promise); + await mount({ appId: app, backend }); + await snapshot([], "processing"); + let done!: Promise; + await act(async () => { done = latest.send("and a footer"); }); + await queueUpdated([{ id: "q1", content: "and a footer" }]); + await act(async () => { await done; }); + await act(async () => { call.reject(new Error("Send message failed: 502")); }); + await settle(); + expect(latest.error).toBeNull(); + expect(shown()).toEqual([["and a footer", true]]); + }); + + test("a reconnect's snapshot that holds the prompt in its queue counts as Base44 having it", async () => { + const backend = makeBackend(); + const call = pending(); + backend.sendMessage.mockReturnValueOnce(call.promise); + await mount({ appId: app, backend }); + await snapshot([], "processing"); + await act(async () => { void latest.send("and a footer"); }); + await snapshot([], "processing", { queue: { items: [{ id: "q1", content: "and a footer" }] } }); + await act(async () => { call.reject(new Error("Send message failed: 502")); }); + await settle(); + expect(latest.error).toBeNull(); + expect(shown()).toEqual([["and a footer", true]]); + }); + test("the first prompt shows while the app is created, and the new app's snapshot settles it", async () => { const backend = makeBackend(); const call = pending<{ id: string; name: string }>(); @@ -585,6 +636,40 @@ describe("questions", () => { expect(latest.status).toBe("waiting"); }); + test("an answer resolves once its call stops waiting, and a failure after that is no refusal", async () => { + const backend = makeBackend(); + const call = pending(); + backend.submitToolCallInput.mockReturnValueOnce(call.promise); + await mount({ appId: app, backend }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1")] })]); + const question = latest.items[0].steps[0].question!; + if (question.kind !== "approval") throw new Error("expected an approval"); + let resolved = false; + await act(async () => { void question.approve().then(() => { resolved = true; }); }); + expect(resolved).toBe(false); + await updated(message("m1", "1", { tool_calls: [{ ...waiting("t1"), status: "running" }] })); + await settle(); + expect(resolved).toBe(true); + await act(async () => { call.reject(new Error("Submit answer failed: 502")); }); + await settle(); + expect(latest.error).toBeNull(); + expect(latest.items[0].steps[0]).toMatchObject({ status: "running" }); + expect(latest.items[0].steps[0].question).toBeUndefined(); + }); + + test("a decline that fails after Base44 stopped the call is no refusal", async () => { + const backend = makeBackend(); + const call = pending(); + backend.submitToolCallInput.mockReturnValueOnce(call.promise); + await mount({ appId: app, backend }); + await snapshot([message("m1", "1", { tool_calls: [waiting("t1", "plan_review")] })]); + await updated(message("m1", "1", { tool_calls: [{ ...waiting("t1", "plan_review"), status: "stopped" }] })); + await act(async () => { call.reject(new Error("Submit answer failed: 502")); }); + await settle(); + expect(latest.error).toBeNull(); + expect(latest.items[0].steps[0].status).toBe("stopped"); + }); + test("a kind this release doesn't know is declined once, then shows stopped when Base44 says so", async () => { const backend = makeBackend(); await mount({ appId: app, backend }); From cbe16d43bcee9361446b0a7cf86bf5c0ec0cf9e7 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 25 Oct 2026 14:25:17 +0200 Subject: [PATCH 2/3] docs(platform): shorten the changelog entry Co-Authored-By: Claude Opus 5.5 --- packages/platform/CHANGELOG.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/platform/CHANGELOG.md b/packages/platform/CHANGELOG.md index ac0d0baf..01dbca4a 100644 --- a/packages/platform/CHANGELOG.md +++ b/packages/platform/CHANGELOG.md @@ -2,15 +2,10 @@ ## Unreleased -- **Breaking:** `ChatItem.createdAt` always ends in `Z` now. Base44's own timestamps used to come - through with no zone, so `new Date()` read them as local time. If you added a `Z` yourself, - remove it, or the date won't parse. -- `useChat` learns from the live updates when Base44 has a prompt or an answer: Base44's copy of - the prompt arrives, its queue holds the prompt, or the question stops waiting. `send()` and the - answer actions resolve then, and a failure after that, such as a timeout on your server while - the turn runs, no longer sets `error` or removes the prompt. Your `ChatBackend` can forward - Send chat message and Submit tool-call input and return their result, however long the turn - takes. +- **Breaking:** `ChatItem.createdAt` always ends in `Z`. If you added one yourself, remove it. +- `send()` and the answer actions resolve once Base44 has the prompt or answer. A backend failure + after that, such as a server timeout mid-turn, no longer sets `error`, so your `ChatBackend` + can simply forward the request. ## 0.5.0 From 73d41dd52a2645da38b7c02933a80c47969e7bf3 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Sun, 25 Oct 2026 15:44:09 +0200 Subject: [PATCH 3/3] refactor(platform): drop the queue check, which Base44 makes unneeded Checked live on a Tiny Sunny app: a send while the AI is busy answers 200 {"queued": true} in under a second, so a queued prompt's send never waits long enough to fail late. Also checked: refusals (404, 401) come back in under half a second, and a caller that disconnects doesn't cancel the turn. ChatBackend's docs now note that a Next.js server action shouldn't wait out the turn, since server actions run one at a time. Co-Authored-By: Claude Opus 5.5 --- packages/platform/src/react/chat-items.ts | 16 +--------- packages/platform/src/react/chat.types.ts | 4 ++- packages/platform/src/react/useChat.ts | 31 ++++++++----------- .../tests/unit/react/chat-items.test.ts | 18 ++--------- .../platform/tests/unit/react/useChat.test.ts | 29 ----------------- 5 files changed, 20 insertions(+), 78 deletions(-) diff --git a/packages/platform/src/react/chat-items.ts b/packages/platform/src/react/chat-items.ts index a39be532..90a6d271 100644 --- a/packages/platform/src/react/chat-items.ts +++ b/packages/platform/src/react/chat-items.ts @@ -164,14 +164,12 @@ export function waitingToolCallIds(sortedMessages: Message[]): Set { ); } -/** @internal A prompt sent from this chat that Base44 has not confirmed, for the app it went to. - * `queued` once Base44's queue holds it: Base44 has it, and it waits to run. */ +/** @internal A prompt sent from this chat that Base44 has not confirmed, for the app it went to. */ export interface PendingPrompt { id: string; content: string; createdAt: string; appId: string; - queued: boolean; } /** @internal Prompts that arrived from Base44 as messages settle the pending ones with the same @@ -186,18 +184,6 @@ export function settlePendingPrompts(pendingPrompts: PendingPrompt[], appId: str return stillPending; } -/** @internal Base44's queue for the app marks the pending prompts it holds, one queued prompt per - * pending one with the same text. Text is the only link here too. */ -export function markQueuedPrompts(pendingPrompts: PendingPrompt[], appId: string, queuedPrompts: string[]): PendingPrompt[] { - const matched = new Set(); - for (const queuedPrompt of queuedPrompts) { - const match = pendingPrompts.find(prompt => !matched.has(prompt) && prompt.appId === appId && prompt.content.trim() === queuedPrompt.trim()); - if (match) matched.add(match); - } - if ([...matched].every(prompt => prompt.queued)) return pendingPrompts; - return pendingPrompts.map(prompt => (matched.has(prompt) && !prompt.queued ? { ...prompt, queued: true } : prompt)); -} - /** @internal Pending prompts as items, drawn after the messages Base44 has. */ export function pendingPromptItems(pendingPrompts: PendingPrompt[]): ChatItem[] { return pendingPrompts.map(prompt => ({ id: prompt.id, role: "user", text: prompt.content, createdAt: prompt.createdAt, steps: [], pending: true })); diff --git a/packages/platform/src/react/chat.types.ts b/packages/platform/src/react/chat.types.ts index 8a878237..0d4ec26c 100644 --- a/packages/platform/src/react/chat.types.ts +++ b/packages/platform/src/react/chat.types.ts @@ -40,7 +40,9 @@ export interface ToolCallAnswer { * finishes the turn, which can take minutes. Forward the request and return its result: the hook * learns from the live updates as soon as Base44 has the prompt or the answer, and a failure after * that, such as a timeout on your server, doesn't set an error. A rejected promise from any call - * before then sets [`error`](#error). + * before then sets [`error`](#error). Next.js server actions run one at a time, so one that waits + * out the turn holds up every later call: resolve it once Base44 hasn't refused the request + * within a few seconds. Base44 refuses at once. * * @example * ```typescript diff --git a/packages/platform/src/react/useChat.ts b/packages/platform/src/react/useChat.ts index 5d481734..c49c5806 100644 --- a/packages/platform/src/react/useChat.ts +++ b/packages/platform/src/react/useChat.ts @@ -1,8 +1,8 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPlatformClient } from "../client/client.js"; -import type { Message, QueueItem, StatusObject } from "../client/modules/chat.events.generated.js"; -import { isWaitingForUser, markQueuedPrompts, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, sortMessages, toItems, unknownKindToolCalls, waitingToolCallIds, type PendingPrompt } from "./chat-items.js"; +import type { Message, StatusObject } from "../client/modules/chat.events.generated.js"; +import { isWaitingForUser, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, sortMessages, toItems, unknownKindToolCalls, waitingToolCallIds, type PendingPrompt } from "./chat-items.js"; import type { UseChatResult, ChatOptions, ChatConnection, ChatError, ChatStatus } from "./chat.types.js"; // Base44's public status carries no reason, so a failed build has one error. @@ -75,7 +75,8 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR // refusal. Each entry holds what resolves the action. // Tool calls answered from here, whose submission ends when Base44 stops reporting them waiting. const answersAwaitingBase44 = useRef(new Map void>()); - // Prompts sent from here, by pending id, until Base44's copy settles them or its queue holds them. + // Prompts sent from here, by pending id, until Base44's copy settles them. A prompt Base44 queues + // needs none: Base44 answers that send at once. const promptsAwaitingBase44 = useRef(new Map void; shown: boolean }>()); // The tool calls Base44 reports waiting, as of the last render. const waitingToolCalls = useRef(new Set()); @@ -153,10 +154,6 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR setPendingPrompts(prompts => settlePendingPrompts(prompts, followedAppId, arrivedPrompts)); } }; - const handleQueue = (queueItems: QueueItem[] | null | undefined) => { - const queuedPrompts = (queueItems ?? []).map(queueItem => queueItem.content ?? ""); - if (queuedPrompts.length) setPendingPrompts(prompts => markQueuedPrompts(prompts, followedAppId, queuedPrompts)); - }; // A snapshot replaces the conversation when it is the first, or follows a rewrite of main // (undo, restore, sync); after a plain rejoin the messages before its window stay. let nextSnapshotReplaces = true; @@ -200,7 +197,6 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR onSnapshot: snapshot => { // A snapshot holds old prompts too, so only an exact match settles one. handleIncomingMessages(snapshot.messages); - handleQueue(snapshot.queue?.items); const replaceMessages = nextSnapshotReplaces; nextSnapshotReplaces = false; setMessages(heldMessages => (replaceMessages ? snapshot.messages : mergeSnapshot(heldMessages, snapshot.messages))); @@ -222,10 +218,7 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR if (event.type === "conversation.changed" && event.data.branch_id == null) nextSnapshotReplaces = true; // A prompt sent while the AI is working waits in Base44's queue: Base44 has it, and it // stays pending until it runs. - if (event.type === "queue.updated" && event.data.branch_id == null) { - setSubmittedTo(null); - handleQueue(event.data.items); - } + if (event.type === "queue.updated" && event.data.branch_id == null) setSubmittedTo(null); }, // Only snapshot_unavailable leaves the subscription running. onError: error => ((error as { code?: string }).code === "snapshot_unavailable" ? reportConnectionError(error) : endLiveUpdates(error)), @@ -312,13 +305,15 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR if (answerTaken) setSubmittedTo(target => (target === followedAppIdRef.current ? null : target)); }, [sortedMessages]); - // A prompt's send ends once Base44 has it: its copy settled the pending prompt, or its queue holds - // it. A prompt not shown yet is still on its way to the list, not settled. + // A prompt's send ends once Base44's copy settled the pending prompt. A prompt not shown yet is + // still on its way to the list, not settled. useEffect(() => { for (const [promptId, sentPrompt] of promptsAwaitingBase44.current) { - const pendingPrompt = pendingPrompts.find(prompt => prompt.id === promptId); - if (pendingPrompt) sentPrompt.shown = true; - if (pendingPrompt ? !pendingPrompt.queued : !sentPrompt.shown) continue; + if (pendingPrompts.some(prompt => prompt.id === promptId)) { + sentPrompt.shown = true; + continue; + } + if (!sentPrompt.shown) continue; promptsAwaitingBase44.current.delete(promptId); sentPrompt.resolve(); } @@ -332,7 +327,7 @@ export function useChat({ appId, backend, onAppCreated }: ChatOptions): UseChatR else if (submissionInFlight) status = "submitted"; const addPendingPrompt = useCallback((content: string, targetAppId: string) => { - const pendingPrompt = { id: `pending-${++pendingPromptCount.current}`, content, createdAt: new Date().toISOString(), appId: targetAppId, queued: false }; + const pendingPrompt = { id: `pending-${++pendingPromptCount.current}`, content, createdAt: new Date().toISOString(), appId: targetAppId }; setPendingPrompts(prompts => [...prompts, pendingPrompt]); return pendingPrompt; }, []); diff --git a/packages/platform/tests/unit/react/chat-items.test.ts b/packages/platform/tests/unit/react/chat-items.test.ts index bceec107..a1e9053f 100644 --- a/packages/platform/tests/unit/react/chat-items.test.ts +++ b/packages/platform/tests/unit/react/chat-items.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Message } from "../../../src/client/modules/chat.events.generated.js"; import type { ToolCall } from "../../../src/client/modules/chat.events.types.js"; -import { toQuestion, isWaitingForUser, markQueuedPrompts, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, SNAPSHOT_SIZE, sortMessages, stepStatus, toItems, toUtcTimestamp, unknownKindToolCalls, waitingToolCallIds } from "../../../src/react/chat-items.js"; +import { toQuestion, isWaitingForUser, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, SNAPSHOT_SIZE, sortMessages, stepStatus, toItems, toUtcTimestamp, unknownKindToolCalls, waitingToolCallIds } from "../../../src/react/chat-items.js"; const message = (id: string, created: string, extra: Partial = {}): Message => ({ id, role: "assistant", metadata: { created_date: created }, ...extra }); const waiting = (id: string, extra: Partial = {}): ToolCall => ({ id, name: "tool", status: "waiting_for_user_input", ...extra }); @@ -179,7 +179,7 @@ describe("timestamps", () => { }); describe("pending prompts", () => { - const pending = (id: string, content: string, appId = "a") => ({ id, content, createdAt: "", appId, queued: false }); + const pending = (id: string, content: string, appId = "a") => ({ id, content, createdAt: "", appId }); test("an arrival settles the pending prompt with its text, of its own app, ignoring outer spaces", () => { const all = [pending("p1", "one"), pending("p2", "two"), pending("p3", "two", "b")]; @@ -191,20 +191,8 @@ describe("pending prompts", () => { expect(settlePendingPrompts([pending("p1", "one"), pending("p2", "two")], "a", ["someone else's"]).map(p => p.id)).toEqual(["p1", "p2"]); }); - test("the queue marks one pending prompt per queued prompt with its text, of its own app", () => { - const all = [pending("p1", "two"), pending("p2", "two"), pending("p3", "two", "b"), pending("p4", "one")]; - const marked = markQueuedPrompts(all, "a", [" two "]); - expect(marked.map(p => [p.id, p.queued])).toEqual([["p1", true], ["p2", false], ["p3", false], ["p4", false]]); - expect(markQueuedPrompts(marked, "a", ["two", "two"]).map(p => p.queued)).toEqual([true, true, false, false]); - }); - - test("a queue that marks nothing new leaves the same list", () => { - const marked = markQueuedPrompts([pending("p1", "one")], "a", ["one"]); - expect(markQueuedPrompts(marked, "a", ["one", "someone else's"])).toBe(marked); - }); - test("a pending prompt is a user item with no steps", () => { - expect(pendingPromptItems([{ id: "p1", content: "hi", createdAt: "t", appId: "a", queued: false }])).toEqual([ + expect(pendingPromptItems([{ id: "p1", content: "hi", createdAt: "t", appId: "a" }])).toEqual([ { id: "p1", role: "user", text: "hi", createdAt: "t", steps: [], pending: true }, ]); }); diff --git a/packages/platform/tests/unit/react/useChat.test.ts b/packages/platform/tests/unit/react/useChat.test.ts index fe482539..3c47fce4 100644 --- a/packages/platform/tests/unit/react/useChat.test.ts +++ b/packages/platform/tests/unit/react/useChat.test.ts @@ -513,35 +513,6 @@ describe("pending prompts", () => { expect(latest.status).toBe("submitted"); }); - test("a prompt Base44 queued stays pending when its send fails afterwards", async () => { - const backend = makeBackend(); - const call = pending(); - backend.sendMessage.mockReturnValueOnce(call.promise); - await mount({ appId: app, backend }); - await snapshot([], "processing"); - let done!: Promise; - await act(async () => { done = latest.send("and a footer"); }); - await queueUpdated([{ id: "q1", content: "and a footer" }]); - await act(async () => { await done; }); - await act(async () => { call.reject(new Error("Send message failed: 502")); }); - await settle(); - expect(latest.error).toBeNull(); - expect(shown()).toEqual([["and a footer", true]]); - }); - - test("a reconnect's snapshot that holds the prompt in its queue counts as Base44 having it", async () => { - const backend = makeBackend(); - const call = pending(); - backend.sendMessage.mockReturnValueOnce(call.promise); - await mount({ appId: app, backend }); - await snapshot([], "processing"); - await act(async () => { void latest.send("and a footer"); }); - await snapshot([], "processing", { queue: { items: [{ id: "q1", content: "and a footer" }] } }); - await act(async () => { call.reject(new Error("Send message failed: 502")); }); - await settle(); - expect(latest.error).toBeNull(); - expect(shown()).toEqual([["and a footer", true]]); - }); test("the first prompt shows while the app is created, and the new app's snapshot settles it", async () => { const backend = makeBackend();