fix(webchat): sessions persist after reconnects (#89017)

* fix(gateway): preserve asserted webchat sessions

* test(gateway): cover stale asserted webchat sessions

* fix(gateway): scope webchat session resume

* chore(protocol): refresh chat send models

* fix: document reconnect session resume protocol

* fix(gateway): keep reconnect resume internal

* gateway: keep reconnect resume options internal

* test(ui): avoid private resume marker lint access
This commit is contained in:
zhang-guiping
2026-06-23 04:02:58 +08:00
committed by GitHub
parent 9122e762d8
commit 2dc2d73b07
18 changed files with 339 additions and 27 deletions
@@ -162,6 +162,7 @@ Rules of thumb:
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
- **Control UI reconnect resume** can preserve the currently visible session for one reconnect send when the Gateway receives the matching `sessionId` from an operator UI client. Ordinary stale sends still create a new `sessionId`.
- **System events** (heartbeat, cron wakeups, exec notifications, gateway bookkeeping) may mutate the session row but do not extend daily/idle reset freshness. Reset rollover discards queued system-event notices for the previous session before the fresh prompt is built.
- **Parent fork policy** uses OpenClaw's active branch when creating a thread or subagent fork. If that branch is too large, OpenClaw starts the child with isolated context instead of failing or inheriting unusable history. The sizing policy is automatic; legacy `session.parentForkMaxTokens` config is removed by `openclaw doctor --fix`.
@@ -97,10 +97,20 @@ describe("lazy protocol validators", () => {
validateChatSendParams({
sessionKey: "global",
agentId: "work",
sessionId: "session-work",
message: "hello",
idempotencyKey: "run-global-work",
}),
).toBe(true);
expect(
validateChatSendParams({
sessionKey: "global",
sessionId: "session-work",
resumeSession: true,
message: "hello",
idempotencyKey: "run-global-work",
}),
).toBe(false);
expect(
validateChatAbortParams({
sessionKey: "global",
+17 -12
View File
@@ -24,7 +24,10 @@ import { copyReplyPayloadMetadata } from "./reply-payload.js";
import type { CommandSessionMetadataChange } from "./reply/command-session-metadata.js";
import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js";
import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.types.js";
import type { GetReplyFromConfig } from "./reply/get-reply.types.js";
import type {
InternalGetReplyFromConfig,
InternalGetReplyOptions,
} from "./reply/get-reply.types.js";
import { finalizeInboundContext } from "./reply/inbound-context.js";
import {
createReplyDispatcher,
@@ -37,7 +40,9 @@ import type { ReplyDispatcher } from "./reply/reply-dispatcher.types.js";
import { runReplyPayloadSendingHook } from "./reply/reply-payload-sending-hook.js";
import { consumeReplyUsageState } from "./reply/reply-usage-state.js";
import type { FinalizedMsgContext, MsgContext } from "./templating.js";
import type { GetReplyOptions, ReplyPayload } from "./types.js";
import type { ReplyPayload } from "./types.js";
type InternalDispatchReplyOptions = Omit<InternalGetReplyOptions, "onBlockReply">;
type ForegroundReplyFenceState = {
generation: number;
@@ -60,9 +65,9 @@ const foregroundReplyFenceByKey = new Map<string, ForegroundReplyFenceState>();
const replyPayloadSendingDispatchers = new WeakSet<ReplyDispatcher>();
function applyRuntimeToolsAllow(
replyOptions: Omit<GetReplyOptions, "onBlockReply"> | undefined,
replyOptions: InternalDispatchReplyOptions | undefined,
toolsAllow: string[] | undefined,
): Omit<GetReplyOptions, "onBlockReply"> | undefined {
): InternalDispatchReplyOptions | undefined {
if (toolsAllow === undefined) {
return replyOptions;
}
@@ -377,9 +382,9 @@ function buildReplyPayloadSendingBeforeDeliver(
}
function bindReplyPayloadRunState(
replyOptions: Omit<GetReplyOptions, "onBlockReply"> | undefined,
replyOptions: InternalDispatchReplyOptions | undefined,
runState: ReplyPayloadRunState,
): Omit<GetReplyOptions, "onBlockReply"> {
): InternalDispatchReplyOptions {
const onAgentRunStart = replyOptions?.onAgentRunStart;
return {
...replyOptions,
@@ -499,8 +504,8 @@ export async function dispatchInboundMessage(params: {
cfg: OpenClawConfig;
dispatcher: ReplyDispatcher;
toolsAllow?: string[];
replyOptions?: Omit<GetReplyOptions, "onBlockReply">;
replyResolver?: GetReplyFromConfig;
replyOptions?: InternalDispatchReplyOptions;
replyResolver?: InternalGetReplyFromConfig;
onSessionMetadataChanges?: (changes: CommandSessionMetadataChange[]) => void;
replyPayloadRunState?: ReplyPayloadRunState;
}): Promise<DispatchInboundResult> {
@@ -558,8 +563,8 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: {
cfg: OpenClawConfig;
dispatcherOptions: ReplyDispatcherWithTypingOptions;
toolsAllow?: string[];
replyOptions?: Omit<GetReplyOptions, "onBlockReply">;
replyResolver?: GetReplyFromConfig;
replyOptions?: InternalDispatchReplyOptions;
replyResolver?: InternalGetReplyFromConfig;
onSessionMetadataChanges?: (changes: CommandSessionMetadataChange[]) => void;
}): Promise<DispatchInboundResult> {
const finalized = finalizeInboundContext(params.ctx);
@@ -660,8 +665,8 @@ export async function dispatchInboundMessageWithDispatcher(params: {
cfg: OpenClawConfig;
dispatcherOptions: ReplyDispatcherOptions;
toolsAllow?: string[];
replyOptions?: Omit<GetReplyOptions, "onBlockReply">;
replyResolver?: GetReplyFromConfig;
replyOptions?: InternalDispatchReplyOptions;
replyResolver?: InternalGetReplyFromConfig;
}): Promise<DispatchInboundResult> {
const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg);
const replyPayloadRunState = {
@@ -1,10 +1,10 @@
// Shared type contracts for dispatch-from-config runtime execution.
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { GetReplyOptions, SourceReplyDeliveryMode } from "../get-reply-options.types.js";
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
import type { FinalizedMsgContext } from "../templating.js";
import type { FormatAbortReplyText, TryFastAbortFromMessage } from "./abort.runtime-types.js";
import type { CommandSessionMetadataChange } from "./command-session-metadata.js";
import type { GetReplyFromConfig } from "./get-reply.types.js";
import type { InternalGetReplyFromConfig, InternalGetReplyOptions } from "./get-reply.types.js";
import type { ReplyDispatchKind, ReplyDispatcher } from "./reply-dispatcher.types.js";
export type DispatchFromConfigResult = {
@@ -23,8 +23,8 @@ export type DispatchFromConfigParams = {
ctx: FinalizedMsgContext;
cfg: OpenClawConfig;
dispatcher: ReplyDispatcher;
replyOptions?: Omit<GetReplyOptions, "onBlockReply">;
replyResolver?: GetReplyFromConfig;
replyOptions?: Omit<InternalGetReplyOptions, "onBlockReply">;
replyResolver?: InternalGetReplyFromConfig;
onSessionMetadataChanges?: (changes: CommandSessionMetadataChange[]) => void;
fastAbortResolver?: TryFastAbortFromMessage;
formatAbortReplyTextResolver?: FormatAbortReplyText;
+9 -3
View File
@@ -45,7 +45,10 @@ import {
import { handleInlineActions } from "./get-reply-inline-actions.js";
import { maybeResolveNativeSlashCommandFastReply } from "./get-reply-native-slash-fast-path.js";
import { runPreparedReply } from "./get-reply-run.js";
import type { ReplySessionBinding } from "./get-reply.types.js";
import type {
InternalGetReplyOptions as BaseInternalGetReplyOptions,
ReplySessionBinding,
} from "./get-reply.types.js";
import { finalizeInboundContext } from "./inbound-context.js";
import { hasInboundMedia, hasInboundMediaForUnderstanding } from "./inbound-media.js";
import { emitPreAgentMessageHooks } from "./message-preprocess-hooks.js";
@@ -61,7 +64,7 @@ import { createTypingController } from "./typing.js";
type ResetCommandAction = "new" | "reset";
type InternalGetReplyOptions = GetReplyOptions & {
type RuntimeInternalGetReplyOptions = BaseInternalGetReplyOptions & {
onSessionPrepared?: (binding: ReplySessionBinding) => void;
};
@@ -325,6 +328,7 @@ export async function getReplyFromConfig(
);
const resolvedOpts =
mergedSkillFilter !== undefined ? { ...opts, skillFilter: mergedSkillFilter } : opts;
const internalResolvedOpts = resolvedOpts as RuntimeInternalGetReplyOptions | undefined;
const agentCfg = cfg.agents?.defaults;
const sessionCfg = cfg.session;
const { defaultProvider, defaultModel, aliasIndex } = resolverTiming.measureSync(
@@ -480,6 +484,8 @@ export async function getReplyFromConfig(
ctx: finalized,
cfg,
commandAuthorized,
requestedSessionId: internalResolvedOpts?.requestedSessionId,
resumeRequestedSession: internalResolvedOpts?.resumeRequestedSession,
}),
);
const {
@@ -501,7 +507,7 @@ export async function getReplyFromConfig(
} = sessionState;
let { abortedLastRun } = sessionState;
resolverTimingSessionKey = sessionKey ?? resolverTimingSessionKey;
(resolvedOpts as InternalGetReplyOptions | undefined)?.onSessionPrepared?.({
internalResolvedOpts?.onSessionPrepared?.({
sessionKey,
sessionId,
storePath,
+13
View File
@@ -10,9 +10,22 @@ export type ReplySessionBinding = {
storePath?: string;
};
export type InternalReplySessionOptions = {
requestedSessionId?: string;
resumeRequestedSession?: boolean;
};
export type InternalGetReplyOptions = GetReplyOptions & InternalReplySessionOptions;
/** Reply resolver signature used by dispatchers and tests for dependency injection. */
export type GetReplyFromConfig = (
ctx: MsgContext,
opts?: GetReplyOptions,
configOverride?: OpenClawConfig,
) => Promise<ReplyPayload | ReplyPayload[] | undefined>;
export type InternalGetReplyFromConfig = (
ctx: MsgContext,
opts?: InternalGetReplyOptions,
configOverride?: OpenClawConfig,
) => Promise<ReplyPayload | ReplyPayload[] | undefined>;
+95
View File
@@ -1817,6 +1817,101 @@ describe("initSessionState reset policy", () => {
expect(result.sessionId).not.toBe(existingSessionId);
});
it("preserves idle rollover when an ordinary send asserts the current session id", async () => {
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
const root = await makeCaseDir("openclaw-reset-idle-requested-session-ordinary-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:main";
const existingSessionId = "webchat-ordinary-session-id";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
reset: { mode: "daily", atHour: 4, idleMinutes: 30 },
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey, Provider: "internal", Surface: "internal" },
cfg,
commandAuthorized: true,
requestedSessionId: existingSessionId,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
});
it("reuses an idle-expired session when a reconnecting client requests current session resume", async () => {
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
const root = await makeCaseDir("openclaw-reset-idle-requested-session-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:main";
const existingSessionId = "webchat-reconnect-session-id";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
reset: { mode: "daily", atHour: 4, idleMinutes: 30 },
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey, Provider: "internal", Surface: "internal" },
cfg,
commandAuthorized: true,
requestedSessionId: existingSessionId,
resumeRequestedSession: true,
});
expect(result.isNewSession).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
});
it("does not reuse an idle-expired session for a stale asserted session id", async () => {
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
const root = await makeCaseDir("openclaw-reset-idle-stale-requested-session-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:main";
const existingSessionId = "webchat-current-session-id";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
},
});
const cfg = {
session: {
store: storePath,
reset: { mode: "daily", atHour: 4, idleMinutes: 30 },
},
} as OpenClawConfig;
const result = await initSessionState({
ctx: { Body: "hello", SessionKey: sessionKey, Provider: "internal", Surface: "internal" },
cfg,
commandAuthorized: true,
requestedSessionId: "webchat-stale-session-id",
resumeRequestedSession: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
});
it("drains stale system events when idle rollover creates a new session", async () => {
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
const root = await makeCaseDir("openclaw-reset-idle-system-events-");
+13 -1
View File
@@ -231,6 +231,8 @@ export async function initSessionState(params: {
ctx: MsgContext;
cfg: OpenClawConfig;
commandAuthorized: boolean;
requestedSessionId?: string;
resumeRequestedSession?: boolean;
}): Promise<SessionInitResult> {
const { ctx, cfg, commandAuthorized } = params;
// Heartbeat, cron-event, and exec-event runs should NEVER trigger session
@@ -433,6 +435,14 @@ export async function initSessionState(params: {
Boolean(entry?.sessionId) &&
typeof entry?.updatedAt === "number" &&
Number.isFinite(entry.updatedAt);
const requestedSessionId = params.requestedSessionId?.trim() || undefined;
const requestedCurrentSession = Boolean(
requestedSessionId && entry?.sessionId && entry.sessionId === requestedSessionId,
);
// Control UI sends sessionId on ordinary sends too, so only the one-shot reconnect
// resume signal is allowed to suppress configured idle/daily rollover.
const reconnectResumeRequested =
params.resumeRequestedSession === true && requestedCurrentSession;
const skipImplicitExpiry = hasProviderOwnedSession(entry) && resetPolicy.configured !== true;
const lifecycleTimestamps = resolveSessionLifecycleTimestamps({
entry,
@@ -477,7 +487,9 @@ export async function initSessionState(params: {
}));
const freshEntry =
(isSystemEvent && canReuseExistingEntry) ||
(((entryFreshness?.fresh ?? false) || (softResetAllowed && canReuseExistingEntry)) &&
(((reconnectResumeRequested && canReuseExistingEntry) ||
(entryFreshness?.fresh ?? false) ||
(softResetAllowed && canReuseExistingEntry)) &&
!terminalMainTranscriptNewerThanRegistry);
// Capture the current session entry before any reset so its transcript can be
// archived afterward. We need to do this for both explicit resets (/new, /reset)
+26 -3
View File
@@ -283,6 +283,26 @@ function shouldIncludeChatSendAckServerTiming(client?: {
return isOperatorUiClient(client);
}
const CONTROL_UI_RECONNECT_RESUME_PARAM = "__controlUiReconnectResume";
function resolveControlUiReconnectResumeParams(
params: unknown,
clientInfo?: { id?: string | null; mode?: string | null },
): { params: unknown; resumeRequested: boolean } {
if (!params || typeof params !== "object" || Array.isArray(params)) {
return { params, resumeRequested: false };
}
const record = params as Record<string, unknown>;
const resumeRequested =
record[CONTROL_UI_RECONNECT_RESUME_PARAM] === true && isOperatorUiClient(clientInfo);
if (!resumeRequested) {
return { params, resumeRequested: false };
}
const validatedParams = { ...record };
delete validatedParams[CONTROL_UI_RECONNECT_RESUME_PARAM];
return { params: validatedParams, resumeRequested: true };
}
function emitOperatorChatSendServerTiming(params: {
context: Pick<GatewayRequestContext, "broadcastToConnIds">;
client?: GatewayClient | null;
@@ -3110,7 +3130,9 @@ export const chatHandlers: GatewayRequestHandlers = {
},
"chat.send": async ({ params, respond, context, client }) => {
const chatSendReceivedAtMs = performance.now();
if (!validateChatSendParams(params)) {
const clientInfo = client?.connect?.client;
const controlUiReconnectResume = resolveControlUiReconnectResumeParams(params, clientInfo);
if (!validateChatSendParams(controlUiReconnectResume.params)) {
respond(
false,
undefined,
@@ -3121,7 +3143,7 @@ export const chatHandlers: GatewayRequestHandlers = {
);
return;
}
const p = params as {
const p = controlUiReconnectResume.params as {
sessionKey: string;
agentId?: string;
sessionId?: string;
@@ -3364,7 +3386,6 @@ export const chatHandlers: GatewayRequestHandlers = {
});
return;
}
const clientInfo = client?.connect?.client;
const chatSendTraceAttributes = {
runId: clientRunId,
sessionKey,
@@ -3957,6 +3978,8 @@ export const chatHandlers: GatewayRequestHandlers = {
}),
}
: {}),
requestedSessionId,
resumeRequestedSession: controlUiReconnectResume.resumeRequested,
abortSignal: activeRunAbort.controller.signal,
images: replyOptionImages,
imageOrder: imageOrder.length > 0 ? imageOrder : undefined,
@@ -5,6 +5,7 @@ import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import type { GetReplyOptions } from "../auto-reply/get-reply-options.types.js";
import type { InternalGetReplyOptions } from "../auto-reply/reply/get-reply.types.js";
import { clearConfigCache } from "../config/config.js";
import type { AgentModelConfig } from "../config/types.agents-shared.js";
import { createDeferred } from "../test-utils/deferred.js";
@@ -2416,6 +2417,76 @@ describe("gateway server chat", () => {
);
});
test("chat.send rejects Control UI reconnect resume marker from public WebChat clients", async () => {
await withGatewayChatHarness(
async ({ ws }) => {
await connectOk(ws, {
client: {
id: GATEWAY_CLIENT_NAMES.WEBCHAT_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
const sendRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",
sessionId: "sess-main",
__controlUiReconnectResume: true,
message: "hello after reconnect",
idempotencyKey: "idem-public-webchat-resume",
});
expect(sendRes.ok).toBe(false);
},
{
headers: { origin: `http://127.0.0.1:${harness.port}` },
},
);
});
test("chat.send forwards Control UI reconnect resume internally", async () => {
await withGatewayChatHarness(
async ({ ws, createSessionDir }) => {
const spy = getReplyFromConfig;
await connectOk(ws, {
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
await createSessionDir();
await writeMainSessionStore();
let capturedOpts: InternalGetReplyOptions | undefined;
mockGetReplyFromConfigOnce(async (_ctx, opts) => {
capturedOpts = opts;
return undefined;
});
const sendRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",
sessionId: "sess-main",
__controlUiReconnectResume: true,
message: "hello after reconnect",
idempotencyKey: "idem-requested-session-id",
});
expect(sendRes.ok).toBe(true);
await vi.waitFor(() => {
expect(spy.mock.calls.length).toBeGreaterThan(0);
}, FAST_WAIT_OPTS);
expect(capturedOpts?.requestedSessionId).toBe("sess-main");
expect(capturedOpts?.resumeRequestedSession).toBe(true);
},
{
headers: { origin: `http://127.0.0.1:${harness.port}` },
},
);
});
test("chat.history hard-caps single oversized nested payloads", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
const historyMaxBytes = 64 * 1024;
+2 -2
View File
@@ -5,8 +5,8 @@ import os from "node:os";
import path from "node:path";
import { vi } from "vitest";
import type { Mock } from "vitest";
import type { GetReplyOptions } from "../auto-reply/get-reply-options.types.js";
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
import type { InternalGetReplyOptions } from "../auto-reply/reply/get-reply.types.js";
import type { MsgContext } from "../auto-reply/templating.js";
import type { AgentBinding } from "../config/types.agents.js";
import type { HooksConfig } from "../config/types.hooks.js";
@@ -20,7 +20,7 @@ import { resolveGlobalSingleton } from "../shared/global-singleton.js";
*/
export type GetReplyFromConfigFn = (
ctx: MsgContext,
opts?: GetReplyOptions,
opts?: InternalGetReplyOptions,
configOverride?: OpenClawConfig,
) => Promise<ReplyPayload | ReplyPayload[] | undefined>;
type CronIsolatedRunFn = (...args: unknown[]) => Promise<RunCronAgentTurnResult>;
@@ -23,6 +23,7 @@ import type {
PluginRuntime as CorePluginRuntime,
} from "openclaw/plugin-sdk/core";
import * as providerEntrySdk from "openclaw/plugin-sdk/provider-entry";
import type { GetReplyOptions as ReplyRuntimeGetReplyOptions } from "openclaw/plugin-sdk/reply-runtime";
import * as zalouserSdk from "openclaw/plugin-sdk/zalouser";
import ts from "typescript";
import { beforeAll, describe, expect, expectTypeOf, it } from "vitest";
@@ -625,6 +626,8 @@ describe("plugin-sdk subpath exports", () => {
"clearHistoryEntriesIfEnabled",
"recordPendingHistoryEntryIfEnabled",
"DEFAULT_GROUP_HISTORY_LIMIT",
"requestedSessionId",
"resumeRequestedSession",
],
});
expectSourceMentions("account-helpers", ["createAccountListHelpers"]);
@@ -1337,6 +1340,11 @@ describe("plugin-sdk subpath exports", () => {
expectTypeOf<CoreOpenClawPluginApi>().toMatchTypeOf<SharedOpenClawPluginApi>();
expectTypeOf<CorePluginRuntime>().toMatchTypeOf<SharedPluginRuntime>();
expectTypeOf<CoreChannelMessageActionContext>().toMatchTypeOf<SharedChannelMessageActionContext>();
type PrivateResumeOptionKeys = Extract<
keyof ReplyRuntimeGetReplyOptions,
"requestedSessionId" | "resumeRequestedSession"
>;
expectTypeOf<PrivateResumeOptionKeys>().toEqualTypeOf<never>();
});
it("keeps runtime entry subpaths importable", async () => {
+12
View File
@@ -820,6 +820,18 @@ describe("connectGateway", () => {
expect(host.lastErrorCode).toBeNull();
});
it("marks the visible session for one reconnect resume after close", () => {
const host = createHost();
host.currentSessionId = " session-before-reconnect ";
connectGateway(host);
const client = requireGatewayClient();
client.emitClose({ code: 1006 });
expect(host.reconnectResumeSessionId).toBe("session-before-reconnect");
});
it("routes exec approval requested events with command spans", () => {
const { host, client } = connectHostGateway();
+7
View File
@@ -151,6 +151,8 @@ type GatewayHost = {
execApprovalBusy: boolean;
execApprovalError: string | null;
updateAvailable: UpdateAvailable | null;
currentSessionId?: string | null;
reconnectResumeSessionId?: string | null;
reconcileWebPushState?: () => Promise<void> | void;
realtimeTalkOptionsOpen?: boolean;
fetchRealtimeTalkCatalog?: () => Promise<void>;
@@ -911,6 +913,11 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
return;
}
host.connected = false;
const currentSessionId =
typeof host.currentSessionId === "string" ? host.currentSessionId.trim() : "";
if (currentSessionId) {
host.reconnectResumeSessionId = currentSessionId;
}
markQueuedChatSendsWaitingForReconnect(
host as unknown as Parameters<typeof markQueuedChatSendsWaitingForReconnect>[0],
);
+6 -1
View File
@@ -161,7 +161,12 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
if (previousSessionKey !== sessionKey) {
resetChatSessionPickerState(state);
}
(state as unknown as { currentSessionId?: string | null }).currentSessionId = null;
const chatSessionState = state as unknown as {
currentSessionId?: string | null;
reconnectResumeSessionId?: string | null;
};
chatSessionState.currentSessionId = null;
chatSessionState.reconnectResumeSessionId = null;
state.chatMessage = "";
state.chatAttachments = [];
state.chatMessages = restoreChatMessagesForSession(state, sessionKey);
+1
View File
@@ -262,6 +262,7 @@ export class OpenClawApp extends LitElement {
chatSessionMessageSubscriptionKey: string | null = null;
chatSessionMessageSubscriptionRequestedKey: string | null = null;
currentSessionId: string | null = null;
reconnectResumeSessionId: string | null = null;
@state() chatLoading = false;
@state() chatSending = false;
@state() chatMessage = "";
+30 -1
View File
@@ -1879,7 +1879,7 @@ describe("sendChatMessage", () => {
expect(state.chatMessages).toHaveLength(1);
});
it("passes the backing session id from history when sending after reconnect", async () => {
it("passes the backing session id from history without resume for ordinary sends", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({
@@ -1904,9 +1904,38 @@ describe("sendChatMessage", () => {
const sendParams = requireRecord(sendRequest?.[1]);
expect(sendParams.sessionKey).toBe("main");
expect(sendParams.sessionId).toBe("session-before-reconnect");
expect(sendParams.resumeSession).toBeUndefined();
expect(sendParams).not.toHaveProperty("__controlUiReconnectResume");
expect(sendParams.message).toBe("continue");
});
it("sends reconnect resume once when the current session matches the reconnect marker", async () => {
const request = vi.fn().mockResolvedValue({ runId: "run-1", status: "started" });
const state = createState({
connected: true,
client: { request } as unknown as ChatState["client"],
currentSessionId: "session-before-reconnect",
reconnectResumeSessionId: "session-before-reconnect",
});
await requestChatSend(state, {
message: "continue",
runId: "run-1",
});
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "main",
sessionId: "session-before-reconnect",
__controlUiReconnectResume: true,
message: "continue",
idempotencyKey: "run-1",
}),
);
expect(state.reconnectResumeSessionId).toBeNull();
});
it("does not reuse another global agent's visible session id for queued sends", async () => {
const request = vi.fn().mockResolvedValue({ runId: "run-work", status: "started" });
const state = createState({
+14
View File
@@ -385,6 +385,7 @@ export type ChatState = {
connected: boolean;
sessionKey: string;
currentSessionId?: string | null;
reconnectResumeSessionId?: string | null;
chatLoading: boolean;
chatMessages: unknown[];
chatMessagesBySession?: ChatMessageCache;
@@ -754,6 +755,12 @@ async function loadChatHistoryUncached(
: typeof res.sessionId === "string" && res.sessionId.trim()
? res.sessionId
: null;
if (
state.reconnectResumeSessionId &&
state.reconnectResumeSessionId !== state.currentSessionId
) {
state.reconnectResumeSessionId = null;
}
state.chatThinkingLevel = res.sessionInfo?.thinkingLevel ?? res.thinkingLevel ?? null;
const resetStream = !state.chatRunId || state.chatRunId === previousRunId;
if (resetStream) {
@@ -956,17 +963,24 @@ export async function requestChatSend(
},
): Promise<ChatSendAck> {
const routing = resolveChatSendRouting(state, params);
const controlUiReconnectResume = Boolean(
routing.sessionId && state.reconnectResumeSessionId === routing.sessionId,
);
const payload = await state.client!.request("chat.send", {
sessionKey: routing.sessionKey,
...(isGlobalSessionKey(routing.sessionKey) && routing.selectedAgentId
? { agentId: routing.selectedAgentId }
: {}),
...(routing.sessionId ? { sessionId: routing.sessionId } : {}),
...(controlUiReconnectResume ? { __controlUiReconnectResume: true } : {}),
message: params.message,
deliver: false,
idempotencyKey: params.runId,
attachments: buildApiAttachments(params.attachments),
});
if (controlUiReconnectResume) {
state.reconnectResumeSessionId = null;
}
return normalizeChatSendAck(payload, params.runId);
}