From 21bfc75648d18f6f6da826d331b40cbdf80ce5f6 Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:47:32 -0600 Subject: [PATCH] fix(ui): show new task prompts immediately without duplicates (#111953) * fix(ui): stabilize new task prompt rendering * test(ui): cover prompt handoff integration * test(ui): initialize prompt handoff fixtures --- ui/src/app/bootstrap.ts | 4 + ui/src/app/context.ts | 19 +++ ui/src/app/initial-user-message-handoff.ts | 50 +++++++ ui/src/e2e/new-session-page.e2e.test.ts | 73 ++++++++- .../sessions/index.create-background.test.ts | 51 +++++++ ui/src/lib/sessions/index.ts | 42 ++++-- ui/src/pages/chat/chat-history.ts | 12 ++ ui/src/pages/chat/chat-pane.test-support.ts | 2 + ui/src/pages/chat/chat-pane.test.ts | 2 + ui/src/pages/chat/chat-pane.ts | 2 + ui/src/pages/chat/chat-state.test.ts | 2 + ui/src/pages/chat/chat-state.ts | 5 +- ui/src/pages/chat/chat-thread.test.ts | 30 ++++ ui/src/pages/chat/chat-thread.ts | 14 +- .../pages/chat/initial-turn-handoff.test.ts | 138 ++++++++++++++++++ ui/src/pages/chat/initial-turn-handoff.ts | 75 +++++++++- ui/src/pages/new-session/new-session-page.ts | 37 ++++- 17 files changed, 538 insertions(+), 20 deletions(-) create mode 100644 ui/src/app/initial-user-message-handoff.ts create mode 100644 ui/src/lib/sessions/index.create-background.test.ts create mode 100644 ui/src/pages/chat/initial-turn-handoff.test.ts diff --git a/ui/src/app/bootstrap.ts b/ui/src/app/bootstrap.ts index 500ff2a897a1..a1ddeceacc9e 100644 --- a/ui/src/app/bootstrap.ts +++ b/ui/src/app/bootstrap.ts @@ -35,6 +35,7 @@ import type { } from "./context.ts"; import { syncCustomThemeStyleTag } from "./custom-theme.ts"; import { createApplicationGateway } from "./gateway-store.ts"; +import { createInitialUserMessageHandoff } from "./initial-user-message-handoff.ts"; import { createNativeChatDrafts } from "./native-bridge.ts"; import { startNativeLinkRouting } from "./native-link-routing.ts"; import { createNativeNotificationsCapability } from "./native-notifications.ts"; @@ -324,6 +325,7 @@ export function bootstrapApplication(): ApplicationRuntime { const nativeNotifications = createNativeNotificationsCapability(); const webPush = createWebPushCapability(gateway); const skillWorkshopRevision = createSkillWorkshopRevisionHandoff(); + const initialUserMessage = createInitialUserMessageHandoff(); applyStartupPresentation(settings); const router = createApplicationRouter(); let pendingGatewayConnection = @@ -396,6 +398,7 @@ export function bootstrapApplication(): ApplicationRuntime { nativeNotifications, webPush, skillWorkshopRevision, + initialUserMessage, navigate: (routeId, options) => { void router .navigate(routeId, context, { history: "push" }, routeLocation(routeId, options)) @@ -457,6 +460,7 @@ export function bootstrapApplication(): ApplicationRuntime { nativeNotifications?.dispose(); webPush.dispose(); skillWorkshopRevision.clear(); + initialUserMessage.clear(); }, }; } diff --git a/ui/src/app/context.ts b/ui/src/app/context.ts index 860ff77b7eea..a73f1dbde5ef 100644 --- a/ui/src/app/context.ts +++ b/ui/src/app/context.ts @@ -58,6 +58,24 @@ export type ApplicationSkillWorkshopRevisionHandoff = { clear: () => void; }; +export type ApplicationInitialUserMessage = { + role: "user"; + content: unknown[]; + timestamp: number; +}; + +type InitialUserMessageHandoff = { + message: ApplicationInitialUserMessage; + owner: object; + sessionKey: string; +}; + +export type ApplicationInitialUserMessageHandoff = { + prepare: (handoff: InitialUserMessageHandoff) => void; + read: (sessionKey: string, owner: object | null) => ApplicationInitialUserMessage | null; + clear: (sessionKey?: string) => void; +}; + export type ApplicationContext = { readonly basePath: string; readonly gateway: ApplicationGateway; @@ -76,6 +94,7 @@ export type ApplicationContext = { readonly nativeNotifications: NativeNotificationsCapability | null; readonly webPush: WebPushCapability; readonly skillWorkshopRevision: ApplicationSkillWorkshopRevisionHandoff; + readonly initialUserMessage: ApplicationInitialUserMessageHandoff; readonly navigate: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void; readonly replace: (routeId: TRouteId, options?: ApplicationNavigationOptions) => void; readonly revalidate: (routeId?: TRouteId) => Promise; diff --git a/ui/src/app/initial-user-message-handoff.ts b/ui/src/app/initial-user-message-handoff.ts new file mode 100644 index 000000000000..95400c7b91f5 --- /dev/null +++ b/ui/src/app/initial-user-message-handoff.ts @@ -0,0 +1,50 @@ +import { areUiSessionKeysEquivalent } from "../lib/sessions/session-key.ts"; +import type { ApplicationInitialUserMessageHandoff } from "./context.ts"; + +// Terminal history removes normal entries; this cap bounds abandoned active-session handoffs. +const MAX_PENDING_INITIAL_USER_MESSAGES = 32; + +export function createInitialUserMessageHandoff(): ApplicationInitialUserMessageHandoff { + const pending = new Map< + string, + Pick[0], "message" | "owner"> + >(); + const findKey = (sessionKey: string) => { + for (const candidate of pending.keys()) { + if (areUiSessionKeysEquivalent(candidate, sessionKey)) { + return candidate; + } + } + return undefined; + }; + return { + prepare: (handoff) => { + const existingKey = findKey(handoff.sessionKey); + if (existingKey) { + pending.delete(existingKey); + } + pending.set(handoff.sessionKey, { message: handoff.message, owner: handoff.owner }); + while (pending.size > MAX_PENDING_INITIAL_USER_MESSAGES) { + const oldestKey = pending.keys().next().value; + if (oldestKey === undefined) { + break; + } + pending.delete(oldestKey); + } + }, + read: (sessionKey, owner) => { + const handoff = pending.get(findKey(sessionKey) ?? ""); + return handoff && handoff.owner === owner ? handoff.message : null; + }, + clear: (sessionKey) => { + if (sessionKey === undefined) { + pending.clear(); + return; + } + const key = findKey(sessionKey); + if (key) { + pending.delete(key); + } + }, + }; +} diff --git a/ui/src/e2e/new-session-page.e2e.test.ts b/ui/src/e2e/new-session-page.e2e.test.ts index 59d9b1931eb7..46185e2ea7a9 100644 --- a/ui/src/e2e/new-session-page.e2e.test.ts +++ b/ui/src/e2e/new-session-page.e2e.test.ts @@ -228,6 +228,72 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { } }); + it("shows the initial prompt while the newly created session is still running", async () => { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionKey = "agent:main:visible-initial-prompt"; + const message = "keep this prompt visible while the agent works"; + const activeOutputTimestamp = Date.now() + 60_000; + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.create": { key: sessionKey, runStarted: true }, + "chat.history": { + messages: [ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "active-tool-call", + name: "read", + arguments: { path: "SKILL.md" }, + }, + ], + timestamp: activeOutputTimestamp, + __openclaw: { id: "active-assistant", seq: 2 }, + }, + { + role: "toolResult", + toolCallId: "active-tool-call", + toolName: "read", + content: [{ type: "text", text: "working" }], + timestamp: activeOutputTimestamp + 1, + __openclaw: { id: "active-tool-result", seq: 3 }, + }, + ], + sessionId: "visible-initial-prompt", + sessionInfo: { hasActiveRun: true, key: sessionKey, status: "running" }, + }, + }, + }); + try { + await page.goto(`${server.baseUrl}new`); + await page.locator(".new-session-page__message").fill(message); + await page.getByRole("button", { name: "Start thread" }).click(); + await page.waitForURL((url) => url.searchParams.get("session") === sessionKey, { + timeout: 30_000, + }); + await gateway.waitForRequest("chat.history"); + await page.getByText("SKILL.md", { exact: true }).waitFor(); + + await expect.poll(() => page.locator(".chat-group.user").textContent()).toContain(message); + const userRow = await page.locator(".chat-group.user").boundingBox(); + const toolRow = await page.getByText("SKILL.md", { exact: true }).boundingBox(); + expect(userRow).not.toBeNull(); + expect(toolRow).not.toBeNull(); + if (!userRow || !toolRow) { + throw new Error("expected visible prompt and tool rows"); + } + expect(userRow.y).toBeLessThan(toolRow.y); + } finally { + await context.close(); + } + }); + it("waits for pasted image reads before enabling session creation", async () => { const context = await browser.newContext({ locale: "en-US", @@ -1695,7 +1761,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { } }); - it("creates a session while a canonical session refresh is pending", async () => { + it("navigates to a created session while canonical session refresh is pending", async () => { const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block", @@ -1761,12 +1827,11 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => { agentId: "main", message: "create during refresh", }); - expect(new URL(page.url()).pathname).toBe("/new"); - - await gateway.resolveDeferred("sessions.list", listResponse); await expect .poll(() => new URL(page.url()).search) .toContain(`session=${encodeURIComponent(sessionKey)}`); + + await gateway.resolveDeferred("sessions.list", listResponse); } finally { await context.close(); } diff --git a/ui/src/lib/sessions/index.create-background.test.ts b/ui/src/lib/sessions/index.create-background.test.ts new file mode 100644 index 000000000000..880dfcbec683 --- /dev/null +++ b/ui/src/lib/sessions/index.create-background.test.ts @@ -0,0 +1,51 @@ +import { expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { SessionsListResult } from "../../api/types.ts"; +import { waitForFast } from "../../test-helpers/wait-for.ts"; +import { createSessionCapability } from "./index.ts"; + +it("returns a created session before background list reconciliation finishes", async () => { + let resolveList: (result: SessionsListResult) => void = () => undefined; + const pendingList = new Promise((resolve) => { + resolveList = resolve; + }); + const key = "agent:main:created-in-background"; + const request = vi.fn(async (method: string) => { + if (method === "sessions.create") { + return { key }; + } + if (method === "sessions.list") { + return await pendingList; + } + throw new Error(`Unexpected request: ${method}`); + }); + const client = { request } as unknown as GatewayBrowserClient; + const sessions = createSessionCapability({ + snapshot: { + client, + connected: true, + hello: null, + assistantAgentId: "main", + sessionKey: "agent:main:main", + }, + subscribe: () => () => undefined, + subscribeEvents: () => () => undefined, + }); + const created = vi.fn(); + sessions.subscribeCreated(created); + + await expect( + sessions.createResult({ agentId: "main" }, { reconciliation: "background" }), + ).resolves.toMatchObject({ key }); + expect(created).not.toHaveBeenCalled(); + + resolveList({ + ts: 2, + path: "(multiple)", + count: 1, + defaults: { modelProvider: null, model: null, contextTokens: null }, + sessions: [{ key, kind: "direct", updatedAt: 2 }], + }); + await waitForFast(() => expect(created).toHaveBeenCalledWith(key)); + sessions.dispose(); +}); diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index c50abb01b632..fe119313d825 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -175,6 +175,8 @@ type SessionConnectionScope = { epoch: number; }; +type SessionCreateReconciliation = "blocking" | "background"; + type SessionMessageSubscription = { key: string; agentId?: string | null; @@ -194,7 +196,10 @@ export type SessionCapability = { reconcileRunTerminal: (terminal: SessionRunTerminal) => boolean; refresh: (options?: SessionRefreshOptions) => Promise; refreshReplacement: (agentId?: string | null) => Promise; - createResult: (params?: SessionCreateParams) => Promise; + createResult: ( + params?: SessionCreateParams, + options?: { reconciliation?: SessionCreateReconciliation }, + ) => Promise; create: (params?: SessionCreateParams) => Promise; patch: SessionPatchRoute; setModelOverride: (key: string, value: string | null | undefined) => void; @@ -960,7 +965,10 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil return refresh({ ...options, force: true }); }; - const createResult = async (params: SessionCreateParams = {}) => { + const createResult = async ( + params: SessionCreateParams = {}, + options: { reconciliation?: SessionCreateReconciliation } = {}, + ) => { const scope = captureConnection(); if (!scope) { return null; @@ -974,14 +982,28 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil if (!isCurrentConnection(scope)) { return null; } - await refreshReplacement(params.agentId); - if (!isCurrentConnection(scope)) { - return null; - } - // Creation may overlap read-only list loading. Notify presentation owners - // after its queued refresh so they never guess from stale list churn. - for (const listener of createdListeners) { - listener(result.key); + const reconcileCreatedSession = async () => { + await refreshReplacement(params.agentId); + if (!isCurrentConnection(scope)) { + return; + } + // Creation may overlap read-only list loading. Notify presentation owners + // after its queued refresh so they never guess from stale list churn. + for (const listener of createdListeners) { + listener(result.key); + } + }; + if (options.reconciliation === "background") { + void reconcileCreatedSession().catch((error: unknown) => { + if (isCurrentConnection(scope)) { + publish({ ...state, error: String(error) }); + } + }); + } else { + await reconcileCreatedSession(); + if (!isCurrentConnection(scope)) { + return null; + } } return result; } catch (error) { diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index ab20b96dfd89..1e9a2b2bcc40 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -9,6 +9,7 @@ import type { SessionBranch, SessionsListResult, } from "../../api/types.ts"; +import type { ApplicationInitialUserMessageHandoff } from "../../app/context.ts"; import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { isAssistantHeartbeatAckForDisplay, @@ -58,6 +59,7 @@ import { preserveOptimisticTailMessages, readTranscriptSequence, } from "./history-merge.ts"; +import { reconcileInitialUserMessageHandoff } from "./initial-turn-handoff.ts"; import { controlUiNowMs, recordControlUiPerformanceEvent, @@ -318,6 +320,7 @@ function collectLateOptimisticTailMessages( export type ChatState = { client: GatewayBrowserClient | null; connected: boolean; + initialUserMessage?: ApplicationInitialUserMessageHandoff; /** Monotonic owner epoch; reconnects can reuse the same client object. */ connectionEpoch: number; sessionKey: string; @@ -1272,6 +1275,15 @@ async function loadChatHistoryUncached( if (lateOptimisticTail.length > 0) { state.chatMessages = [...state.chatMessages, ...lateOptimisticTail]; } + if (state.initialUserMessage) { + reconcileInitialUserMessageHandoff( + state.initialUserMessage, + state, + sessionKey, + authoritativeMessages, + isSessionRunActive(res.sessionInfo ?? {}), + ); + } retireHistoryProvenSteeredChips(state); state.chatHistoryPagination = reconciledHistory?.pagination ?? nextPagination; state.currentSessionId = nextSessionId; diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index 9b6a0dd24fb2..bb56fec35532 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -10,6 +10,7 @@ import type { ControlUiSessionPullRequest } from "../../../../src/gateway/contro import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { GatewaySessionRow } from "../../api/types.ts"; import type { ApplicationContext } from "../../app/context.ts"; +import { createInitialUserMessageHandoff } from "../../app/initial-user-message-handoff.ts"; import type { CatalogSessionKey } from "../../lib/sessions/catalog-key.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; import "./chat-pane.ts"; @@ -105,6 +106,7 @@ export function createSessionContext( terminalEnabled: false, }, }, + initialUserMessage: createInitialUserMessageHandoff(), sessions, } as unknown as ApplicationContext; } diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 47bc7fbc07e6..3d80f0f65c86 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -14,6 +14,7 @@ import type { import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { GatewaySessionRow } from "../../api/types.ts"; import type { ApplicationContext } from "../../app/context.ts"; +import { createInitialUserMessageHandoff } from "../../app/initial-user-message-handoff.ts"; import { buildCatalogSessionKey, type CatalogSessionKey } from "../../lib/sessions/catalog-key.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; import { @@ -85,6 +86,7 @@ function createInitializationContext(): ApplicationContext { }, agentSelection: { state: { selectedId: "main" } }, agents: { state: { agentsList: null } }, + initialUserMessage: createInitialUserMessageHandoff(), sessions: {}, } as unknown as ApplicationContext; } diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 56d801ac638b..252542864dde 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -220,6 +220,7 @@ import { storedChatOutboxScopeKey, } from "./composer-persistence.ts"; import { exportChatMarkdown } from "./export.ts"; +import { admitInitialUserMessageHandoff } from "./initial-turn-handoff.ts"; import { hasAbortableSessionRun, reconcileStaleChatRunAfterSessionStatePublication, @@ -2212,6 +2213,7 @@ class ChatPane extends OpenClawLightDomElement { pageState.chatHistoryPagination = snapshot.pagination; pageState.currentSessionId = snapshot.sessionId; } + admitInitialUserMessageHandoff(pageState.initialUserMessage, pageState, initialSessionKey); } } chatState.attach(pageState); diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index 19d27dc309cf..6120f15ef6b6 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -1,6 +1,7 @@ import type { ReactiveController, ReactiveControllerHost } from "lit"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as assistantIdentity from "../../app/assistant-identity.ts"; +import { createInitialUserMessageHandoff } from "../../app/initial-user-message-handoff.ts"; import { buildFallbackSlashCommands, replaceSlashCommands, @@ -517,6 +518,7 @@ describe("route composer fallback", () => { assistantAgentId: "main", agentsList: { defaultId: "main", mainKey: "main" }, hello: null, + initialUserMessage: createInitialUserMessageHandoff(), sessionKey: "agent:main:first", chatMessage, chatComposerFallbackByScope: {}, diff --git a/ui/src/pages/chat/chat-state.ts b/ui/src/pages/chat/chat-state.ts index de835a15faf5..caa9e1d3180a 100644 --- a/ui/src/pages/chat/chat-state.ts +++ b/ui/src/pages/chat/chat-state.ts @@ -111,7 +111,7 @@ import { storedChatOutboxScopeKey, type StoredChatOutboxScope, } from "./composer-persistence.ts"; -import { admitInitialTurnHandoff } from "./initial-turn-handoff.ts"; +import { admitInitialTurnHandoff, admitInitialUserMessageHandoff } from "./initial-turn-handoff.ts"; import { handleChatDraftChange, handleChatInputHistoryKey, @@ -183,6 +183,7 @@ export type ChatPageHost = ChatHost & SessionWorkspaceHost & BackgroundTasksHost & { sessions: SessionCapability; + initialUserMessage: ApplicationContext["initialUserMessage"]; settings: UiSettings; password: string; onboarding: boolean; @@ -552,6 +553,7 @@ export function resetChatStateForRouteSession( // switchPaneSession requests an update only after adopting the new baseline. syncVisibleChatQueueProjection(state, { requestUpdate: false }); const initialTurn = admitInitialTurnHandoff(state, sessionKey); + admitInitialUserMessageHandoff(state.initialUserMessage, state, sessionKey); const { fallback } = resolveChatComposerMemoryFallback(state, sessionKey); if (fallback) { state.chatMessage = fallback.message; @@ -1224,6 +1226,7 @@ export function createPageState( const appConfig = context.config.current; const state = { sessions: context.sessions, + initialUserMessage: context.initialUserMessage, settings, password: "", onboarding: false, diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 5399403a349f..f5171c81c47c 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -2341,6 +2341,36 @@ describe("buildCachedChatItems", () => { expect(messageAt(groupAt(groups, 0), 1).duplicateCount).toBeUndefined(); }); + it("hides a pending send after history accepts its idempotency key", () => { + const groups = messageGroups({ + messages: [ + { + role: "user", + content: "accepted prompt", + timestamp: 1, + __openclaw: { idempotencyKey: "accepted-run:user", seq: 1 }, + }, + ], + queue: [ + { + id: "pending-send-1", + text: "accepted prompt", + createdAt: 2, + sendRunId: "accepted-run", + sendSubmittedAtMs: 10, + sendState: "sending", + }, + ], + }); + + expect(groups).toHaveLength(1); + expect(groupAt(groups, 0).messages).toHaveLength(1); + expect(messageRecord(groupAt(groups, 0))["__openclaw"]).toMatchObject({ + idempotencyKey: "accepted-run:user", + seq: 1, + }); + }); + it("keeps failed queued sends out of the thread", () => { const groups = messageGroups({ queue: [ diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index bdfe184eec49..da57573a9a7e 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -46,6 +46,7 @@ import { shouldRenderQueuedSendInThread, } from "./chat-progress.ts"; import { getOrCreateSessionCacheValue } from "./session-cache.ts"; +import { chatMessagesContainQueuedSend } from "./steer-lifecycle.ts"; import type { PlanStatus } from "./tool-stream.ts"; import { buildUserChatMessageContentBlocks } from "./user-message-content.ts"; @@ -1291,8 +1292,17 @@ function buildChatItems(props: BuildChatItemsProps): Array queued.sendState === "waiting-model"); - const futureQueuedSends = queuedSends.filter((queued) => !activeRunQueuedSends.includes(queued)); + // Once authoritative history carries the send id, that message owns the bubble. + // Keep the queue row for run progress and delivery retirement, but do not render both copies. + const threadQueuedSends = queuedSends.filter( + (queued) => !chatMessagesContainQueuedSend(history, queued, true), + ); + const activeRunQueuedSends = threadQueuedSends.filter( + (queued) => queued.sendState === "waiting-model", + ); + const futureQueuedSends = threadQueuedSends.filter( + (queued) => !activeRunQueuedSends.includes(queued), + ); const futureQueuedTimestamp = futureQueuedSends.reduce( (earliest, queued) => earliest == null ? queued.createdAt : Math.min(earliest, queued.createdAt), diff --git a/ui/src/pages/chat/initial-turn-handoff.test.ts b/ui/src/pages/chat/initial-turn-handoff.test.ts new file mode 100644 index 000000000000..40d3cdff20ac --- /dev/null +++ b/ui/src/pages/chat/initial-turn-handoff.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { createInitialUserMessageHandoff } from "../../app/initial-user-message-handoff.ts"; +import { + admitInitialUserMessageHandoff, + prepareInitialUserMessageHandoff, + reconcileInitialUserMessageHandoff, +} from "./initial-turn-handoff.ts"; + +describe("initial user message handoff", () => { + it("reprojects an accepted first prompt across state replacement until history owns it", () => { + const sessionKey = "agent:main:new-session"; + const hello = {}; + const handoff = createInitialUserMessageHandoff(); + prepareInitialUserMessageHandoff( + handoff, + sessionKey, + { + text: "show this while the run is active", + createdAt: 123, + }, + hello, + ); + + const otherSession = { chatMessages: [] as unknown[], hello }; + expect(admitInitialUserMessageHandoff(handoff, otherSession, "agent:main:other")).toBe(false); + expect(otherSession.chatMessages).toEqual([]); + + const createdSession = { chatMessages: [] as unknown[], hello }; + expect(admitInitialUserMessageHandoff(handoff, createdSession, sessionKey)).toBe(true); + expect(createdSession.chatMessages).toEqual([ + { + role: "user", + content: [{ type: "text", text: "show this while the run is active" }], + timestamp: 123, + }, + ]); + expect(admitInitialUserMessageHandoff(handoff, createdSession, sessionKey)).toBe(false); + + const secondSessionKey = "agent:main:second-new-session"; + prepareInitialUserMessageHandoff( + handoff, + secondSessionKey, + { + text: "keep the other active prompt too", + createdAt: 124, + }, + hello, + ); + const secondActiveSession = { chatMessages: [] as unknown[], hello }; + expect(admitInitialUserMessageHandoff(handoff, secondActiveSession, secondSessionKey)).toBe( + true, + ); + + const assistantOutput = { + role: "assistant", + content: [{ type: "text", text: "already working" }], + }; + const remountedSession = { chatMessages: [assistantOutput] as unknown[], hello }; + expect(admitInitialUserMessageHandoff(handoff, remountedSession, sessionKey)).toBe(true); + expect(remountedSession.chatMessages).toEqual([ + ...createdSession.chatMessages, + assistantOutput, + ]); + + const persisted = { + role: "user", + content: [{ type: "text", text: "show this while the run is active" }], + __openclaw: { seq: 1 }, + }; + remountedSession.chatMessages = [persisted]; + expect( + reconcileInitialUserMessageHandoff(handoff, remountedSession, sessionKey, [persisted], true), + ).toBe(false); + const activeRunReset = { chatMessages: [] as unknown[], hello }; + expect(admitInitialUserMessageHandoff(handoff, activeRunReset, sessionKey)).toBe(true); + activeRunReset.chatMessages = [persisted]; + expect( + reconcileInitialUserMessageHandoff(handoff, activeRunReset, sessionKey, [persisted], false), + ).toBe(false); + expect(admitInitialUserMessageHandoff(handoff, { chatMessages: [], hello }, sessionKey)).toBe( + false, + ); + }); + + it("does not duplicate a first prompt that history already loaded", () => { + const sessionKey = "agent:main:main"; + const routeSessionKey = "main"; + const hello = {}; + const handoff = createInitialUserMessageHandoff(); + prepareInitialUserMessageHandoff( + handoff, + sessionKey, + { + text: "history won the race", + createdAt: 123, + }, + hello, + ); + const persisted = { + role: "user", + content: [{ type: "text", text: "history won the race" }], + __openclaw: { seq: 1 }, + }; + const createdSession = { chatMessages: [persisted] as unknown[], hello }; + + expect( + reconcileInitialUserMessageHandoff( + handoff, + createdSession, + routeSessionKey, + [persisted], + false, + ), + ).toBe(false); + expect(createdSession.chatMessages).toEqual([persisted]); + expect( + admitInitialUserMessageHandoff(handoff, { chatMessages: [], hello }, routeSessionKey), + ).toBe(false); + }); + + it("does not expose a pending prompt after reconnecting", () => { + const sessionKey = "agent:main:new-session"; + const originalConnection = {}; + const handoff = createInitialUserMessageHandoff(); + prepareInitialUserMessageHandoff( + handoff, + sessionKey, + { text: "private prompt", createdAt: 123 }, + originalConnection, + ); + + const replacementGatewaySession = { chatMessages: [] as unknown[], hello: {} }; + expect(admitInitialUserMessageHandoff(handoff, replacementGatewaySession, sessionKey)).toBe( + false, + ); + expect(replacementGatewaySession.chatMessages).toEqual([]); + }); +}); diff --git a/ui/src/pages/chat/initial-turn-handoff.ts b/ui/src/pages/chat/initial-turn-handoff.ts index 9c92a025a36c..a56045490f86 100644 --- a/ui/src/pages/chat/initial-turn-handoff.ts +++ b/ui/src/pages/chat/initial-turn-handoff.ts @@ -1,6 +1,13 @@ +import type { + ApplicationInitialUserMessage, + ApplicationInitialUserMessageHandoff, +} from "../../app/context.ts"; import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts"; -import { releaseChatAttachmentPayloads } from "./attachment-payload-store.ts"; +import { + getChatAttachmentDataUrl, + releaseChatAttachmentPayloads, +} from "./attachment-payload-store.ts"; import { markLocalRecoveryItem, markVolatileQueuedMessage, @@ -8,6 +15,8 @@ import { type ChatQueueScopedSessionHost, writeChatQueueForScope, } from "./chat-queue.ts"; +import { messageDisplaySignature } from "./history-merge.ts"; +import { buildUserChatMessageContentBlocks } from "./user-message-content.ts"; const INITIAL_TURN_HANDOFF_TTL_MS = 60_000; @@ -37,6 +46,27 @@ export function prepareInitialTurnHandoff(sessionKey: string, item: ChatQueueIte pending = { item, sessionKey, timer }; } +/** Hands the accepted first prompt to chat before transcript persistence catches up. */ +export function prepareInitialUserMessageHandoff( + handoff: ApplicationInitialUserMessageHandoff, + sessionKey: string, + item: Pick, + owner: object, +): void { + const durableAttachments = item.attachments?.map((attachment) => { + const dataUrl = getChatAttachmentDataUrl(attachment); + return dataUrl ? { ...attachment, dataUrl, previewUrl: dataUrl } : attachment; + }); + const message: ApplicationInitialUserMessage = { + role: "user", + content: buildUserChatMessageContentBlocks(item.text, durableAttachments), + timestamp: item.createdAt, + }; + // Keep the projection until terminal history owns it so active first turns + // survive later pane/history resets. + handoff.prepare({ message, owner, sessionKey }); +} + function consumeInitialTurnHandoff(sessionKey: string): ChatQueueItem | null { if (!pending || !areUiSessionKeysEquivalent(pending.sessionKey, sessionKey)) { return null; @@ -62,3 +92,46 @@ export function admitInitialTurnHandoff( markVolatileQueuedMessage(host, item.id); return true; } + +export function admitInitialUserMessageHandoff( + handoff: ApplicationInitialUserMessageHandoff, + host: { chatMessages: unknown[]; hello?: object | null }, + sessionKey: string, +): boolean { + const message = handoff.read(sessionKey, host.hello ?? null); + if (!message) { + return false; + } + const signature = messageDisplaySignature(message); + const matchingMessage = host.chatMessages.find( + (candidate) => signature && messageDisplaySignature(candidate) === signature, + ); + if (matchingMessage) { + return false; + } + host.chatMessages = [message, ...host.chatMessages]; + return true; +} + +/** Keeps the accepted prompt projected until authoritative history owns it. */ +export function reconcileInitialUserMessageHandoff( + handoff: ApplicationInitialUserMessageHandoff, + host: { chatMessages: unknown[]; hello?: object | null }, + sessionKey: string, + authoritativeMessages: unknown[], + runActive: boolean, +): boolean { + const message = handoff.read(sessionKey, host.hello ?? null); + if (!message) { + return false; + } + const signature = messageDisplaySignature(message); + const historyOwnsMessage = authoritativeMessages.some( + (candidate) => signature && messageDisplaySignature(candidate) === signature, + ); + if (historyOwnsMessage && !runActive) { + handoff.clear(sessionKey); + return false; + } + return admitInitialUserMessageHandoff(handoff, host, sessionKey); +} diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 2fbc62125de1..335a63c72488 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -19,6 +19,7 @@ import "../../styles/chat.css"; import "../../styles/new-session.css"; import { buildChatApiAttachments, restoreChatApiAttachments } from "../chat/attachment-api.ts"; import { renderWelcomeState } from "../chat/components/chat-welcome.ts"; +import { prepareInitialUserMessageHandoff } from "../chat/initial-turn-handoff.ts"; import { NewSessionAttachmentDraft } from "./attachment-draft.ts"; import * as catalog from "./catalog-target.ts"; import { CloudProfileDiscovery, selectProfiles } from "./cloud-profile-discovery.ts"; @@ -687,13 +688,15 @@ class NewSessionPage extends OpenClawLightDomElement { ? this.pendingCloud.gatewayUrl : context.gateway.connection.gatewayUrl; const submissionClient = context.gateway.snapshot.client; - if (!submissionClient) { + const submissionConnection = context.gateway.snapshot.hello; + if (!submissionClient || !submissionConnection) { return; } const submissionRecoveryScope = pendingCloud ? this.pendingCloud.recoveryScope : submissionClient.recoveryScope; const requestId = ++this.submitRequestToken; + const submittedAt = Date.now(); this.submitting = true; this.error = null; // Retire hidden pickers before their late requests can mutate this submitted draft. @@ -761,7 +764,9 @@ class NewSessionPage extends OpenClawLightDomElement { const result = pendingCloud && this.pendingCloud.phase !== "creating" ? { key: this.pendingCloud.sessionKey, initialRun: { status: "idle" as const } } - : await context.sessions.createResult(cloudCreateParams ?? createParams); + : await context.sessions.createResult(cloudCreateParams ?? createParams, { + reconciliation: "background", + }); if (requestId !== this.submitRequestToken && !cloudProfileId) { return; } @@ -859,8 +864,24 @@ class NewSessionPage extends OpenClawLightDomElement { this.error = cloudStart.error || t("newSession.createFailed"); return; } + if (requestId !== this.submitRequestToken) { + return; + } + prepareInitialUserMessageHandoff( + context.initialUserMessage, + result.key, + { + text: submissionCloudRecovery.message, + attachments, + createdAt: submittedAt, + }, + submissionConnection, + ); this.attachmentDraft.clearAfterSubmit(true); } else { + if (requestId !== this.submitRequestToken) { + return; + } const handedOffAttachments = result.initialRun.status === "rejected" && retainRejectedInitialTurn({ @@ -871,6 +892,18 @@ class NewSessionPage extends OpenClawLightDomElement { message, sessionKey: result.key, }); + if (result.initialRun.status === "started") { + prepareInitialUserMessageHandoff( + context.initialUserMessage, + result.key, + { + text: message, + attachments, + createdAt: submittedAt, + }, + submissionConnection, + ); + } this.attachmentDraft.clearAfterSubmit(!handedOffAttachments); } if (requestId !== this.submitRequestToken) {