mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
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
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<TRouteId extends string = string> = {
|
||||
readonly basePath: string;
|
||||
readonly gateway: ApplicationGateway;
|
||||
@@ -76,6 +94,7 @@ export type ApplicationContext<TRouteId extends string = string> = {
|
||||
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<void>;
|
||||
|
||||
@@ -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<Parameters<ApplicationInitialUserMessageHandoff["prepare"]>[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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<SessionsListResult>((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();
|
||||
});
|
||||
@@ -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<void>;
|
||||
refreshReplacement: (agentId?: string | null) => Promise<void>;
|
||||
createResult: (params?: SessionCreateParams) => Promise<SessionCreateOutcome | null>;
|
||||
createResult: (
|
||||
params?: SessionCreateParams,
|
||||
options?: { reconciliation?: SessionCreateReconciliation },
|
||||
) => Promise<SessionCreateOutcome | null>;
|
||||
create: (params?: SessionCreateParams) => Promise<string | null>;
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<ChatItem | MessageGro
|
||||
});
|
||||
}
|
||||
const queuedSends = Array.isArray(props.queue) ? props.queue : [];
|
||||
const activeRunQueuedSends = queuedSends.filter((queued) => 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<number | null>(
|
||||
(earliest, queued) =>
|
||||
earliest == null ? queued.createdAt : Math.min(earliest, queued.createdAt),
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<ChatQueueItem, "attachments" | "createdAt" | "text">,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user