From 9da2f7cf812cf8ad232fbaedf2edd96994dfa953 Mon Sep 17 00:00:00 2001 From: Statxc Date: Fri, 8 May 2026 09:11:17 -0500 Subject: [PATCH] fix(gateway): reset webchat /new in place when dmScope is main (#77434) (#71170) Merged via squash. Prepared head SHA: 96a9a83eaccc615466c92039d2b43e15057f309a Co-authored-by: statxc <181730535+statxc@users.noreply.github.com> Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com> Reviewed-by: @velvet-shark --- CHANGELOG.md | 1 + docs/tools/slash-commands.md | 2 +- docs/web/control-ui.md | 2 +- src/gateway/server-methods/sessions.ts | 41 ++++++++++++ .../server.sessions.reset-hooks.test.ts | 64 ++++++++++++++++++- 5 files changed, 107 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b10de4941..b4c94d7c930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -653,6 +653,7 @@ Docs: https://docs.openclaw.ai - Agents/current-time: split UTC into a separate `Reference UTC:` prompt line so local `Current time:` stays anchored to the user's timezone. (#42654) Thanks @chencheng-li. - Agents/reasoning: keep embedded reasoning deltas raw for correct same-line streaming while preserving formatted Telegram, Feishu, Discord, and heartbeat delivery at the channel edge. (#78397) Thanks @medns. - Agents/failover: rotate auth profiles before deferred cooldown marking on rate-limit failures, so file-lock contention cannot stall profile failover. Fixes #57281. (#57283) Thanks @jeremyknows. +- Gateway/sessions: when `session.dmScope: "main"` is configured, route a bare webchat `/new` against the agent's main session (`sessions.create` with `emitCommandHooks=true`) to an in-place reset instead of creating a parallel `dashboard:` child, matching `/new` behavior on Telegram/Discord. Fixes #77434. (#71170) Thanks @statxc. ## 2026.5.3-1 diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 07735a3a15b..f0a32073f1e 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -125,7 +125,7 @@ Current source-of-truth: - `/new [model]` starts a new session; `/reset` is the reset alias. - - Control UI intercepts typed `/new` to create and switch to a fresh dashboard session; typed `/reset` still runs the Gateway's in-place reset. + - Control UI intercepts typed `/new` to create and switch to a fresh dashboard session, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; in that case `/new` resets the main session in place. Typed `/reset` still runs the Gateway's in-place reset. - `/reset soft [message]` keeps the current transcript, drops reused CLI backend session ids, and reruns startup/system-prompt loading in-place. - `/compact [instructions]` compacts the session context. See [Compaction](/concepts/compaction). - `/stop` aborts the current run. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 8a9edd50643..ce014cb893b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -165,7 +165,7 @@ Imported themes are stored only in the current browser profile. They are not wri - Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed. - The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options. - If you send a message while a model picker change for the same session is still saving, the composer waits for that session patch before calling `chat.send` so the send uses the selected model. - - Typing `/new` in the Control UI creates and switches to the same fresh dashboard session as New Chat. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session. + - Typing `/new` in the Control UI creates and switches to the same fresh dashboard session as New Chat, except when `session.dmScope: "main"` is configured and the current parent is the agent's main session; in that case it resets the main session in place. Typing `/reset` keeps the Gateway's explicit in-place reset for the current session. - The chat model picker requests the Gateway's configured model view. If `agents.defaults.models` is present, that allowlist drives the picker. Otherwise the picker shows explicit `models.providers.*.models` entries plus providers with usable auth. The full catalog stays available through the debug `models.list` RPC with `view: "all"`. - When fresh Gateway session usage reports include current context tokens, the chat composer area shows a compact context usage indicator. It switches to warning styling at high context pressure and, at recommended compaction levels, shows a compact button that runs the normal session compaction path. Stale token snapshots are hidden until the Gateway reports fresh usage again. diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index dd3de12dff0..0b29745097c 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -26,6 +26,7 @@ import { type SessionEntry, updateSessionStore, } from "../../config/sessions.js"; +import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createInternalHookEvent, @@ -1023,6 +1024,46 @@ export const sessionsHandlers: GatewayRequestHandlers = { } canonicalParentSessionKey = parent.canonicalKey; } + if ( + canonicalParentSessionKey && + p.emitCommandHooks === true && + !requestedKey && + !resolveOptionalInitialSessionMessage(p) && + cfg.session?.dmScope === "main" + ) { + const parentAgentId = normalizeAgentId( + resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? resolveDefaultAgentId(cfg), + ); + const parentMainKey = resolveAgentMainSessionKey({ cfg, agentId: parentAgentId }); + if (canonicalParentSessionKey === parentMainKey) { + const { performGatewaySessionReset } = await loadSessionsRuntimeModule(); + const resetResult = await performGatewaySessionReset({ + key: canonicalParentSessionKey, + reason: "new", + commandSource: "webchat", + }); + if (!resetResult.ok) { + respond(false, undefined, resetResult.error); + return; + } + respond( + true, + { + ok: true, + key: resetResult.key, + sessionId: resetResult.entry.sessionId, + entry: resetResult.entry, + runStarted: false, + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: resetResult.key, + reason: "new", + }); + return; + } + } if (canonicalParentSessionKey && p.emitCommandHooks === true) { const { entry: parentEntry } = loadSessionEntry(canonicalParentSessionKey); const parentAgentId = normalizeAgentId( diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index f0efa97cda8..1557718667e 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect, test } from "vitest"; -import { embeddedRunMock, writeSessionStore } from "./test-helpers.js"; +import { embeddedRunMock, testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, bootstrapCacheMocks, @@ -410,6 +410,68 @@ test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks aga ); }); +test("sessions.create with emitCommandHooks=true resets parent in place when session.dmScope is 'main' (#77434)", async () => { + const { dir } = await createSessionStoreDir(); + const transcriptPath = path.join(dir, "sess-parent-dms.jsonl"); + await fs.writeFile( + transcriptPath, + `${JSON.stringify({ + type: "message", + id: "m1", + message: { role: "user", content: "hello before /new" }, + })}\n`, + "utf-8", + ); + + testState.sessionConfig = { dmScope: "main" }; + try { + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-parent-dms", + sessionFile: transcriptPath, + updatedAt: Date.now(), + }, + }, + }); + + const result = await directSessionReq<{ + ok: boolean; + key: string; + sessionId: string; + runStarted: boolean; + }>("sessions.create", { + parentSessionKey: "main", + emitCommandHooks: true, + }); + expect(result.ok).toBe(true); + // Reset-in-place: response key matches the parent main key, NOT a dashboard child. + expect(result.payload?.key).toBe("agent:main:main"); + expect(result.payload?.runStarted).toBe(false); + expect(result.payload?.sessionId).not.toBe("sess-parent-dms"); + + expect(sessionLifecycleHookMocks.runSessionEnd).toHaveBeenCalledTimes(1); + expect(sessionLifecycleHookMocks.runSessionStart).toHaveBeenCalledTimes(1); + const [endEvent] = ( + sessionLifecycleHookMocks.runSessionEnd.mock.calls as unknown as Array<[unknown, unknown]> + )[0] ?? [undefined, undefined]; + const [startEvent] = ( + sessionLifecycleHookMocks.runSessionStart.mock.calls as unknown as Array<[unknown, unknown]> + )[0] ?? [undefined, undefined]; + expect(endEvent).toMatchObject({ + sessionId: "sess-parent-dms", + sessionKey: "agent:main:main", + reason: "new", + }); + expect(startEvent).toMatchObject({ + sessionKey: "agent:main:main", + resumedFrom: "sess-parent-dms", + }); + } finally { + testState.sessionConfig = undefined; + } +}); + test("sessions.create without emitCommandHooks does not fire command:new hook (#76957)", async () => { const { dir } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-parent2", "hello from parent 2");