diff --git a/packages/platform/CHANGELOG.md b/packages/platform/CHANGELOG.md index bc331411..01dbca4a 100644 --- a/packages/platform/CHANGELOG.md +++ b/packages/platform/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- **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 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..90a6d271 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 }; diff --git a/packages/platform/src/react/chat.types.ts b/packages/platform/src/react/chat.types.ts index 243a2f21..0d4ec26c 100644 --- a/packages/platform/src/react/chat.types.ts +++ b/packages/platform/src/react/chat.types.ts @@ -37,8 +37,12 @@ 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). 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 @@ -114,15 +118,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 +141,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..c49c5806 100644 --- a/packages/platform/src/react/useChat.ts +++ b/packages/platform/src/react/useChat.ts @@ -70,8 +70,16 @@ 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. 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()); // The app `create` made, shown while `appId` is null. Any change to `appId` forgets it. const [createdAppId, setCreatedAppId] = useState(null); @@ -224,8 +232,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 +241,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 +279,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 +293,32 @@ 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'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) { + if (pendingPrompts.some(prompt => prompt.id === promptId)) { + sentPrompt.shown = true; + continue; + } + if (!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"; @@ -313,11 +342,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..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, mergeMessage, mergeSnapshot, pendingPromptItems, settlePendingPrompts, SNAPSHOT_SIZE, sortMessages, stepStatus, toItems, 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 }); @@ -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,6 +162,22 @@ 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 }); diff --git a/packages/platform/tests/unit/react/useChat.test.ts b/packages/platform/tests/unit/react/useChat.test.ts index 70739a31..3c47fce4 100644 --- a/packages/platform/tests/unit/react/useChat.test.ts +++ b/packages/platform/tests/unit/react/useChat.test.ts @@ -492,6 +492,28 @@ 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("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 +607,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 });