From 705ea32ab78841d3dcce99244f5823de8a94b16c Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Tue, 5 May 2026 21:28:24 -0500 Subject: [PATCH] fix(sessions): restore Control UI new hooks --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 4 + .../OpenClawProtocol/GatewayModels.swift | 4 + src/gateway/protocol/schema/sessions.ts | 1 + .../server-methods/sessions.runtime.ts | 2 + src/gateway/server-methods/sessions.ts | 57 +++++++ .../server.sessions.reset-hooks.test.ts | 142 ++++++++++++++++++ src/gateway/session-reset-service.ts | 2 +- ui/src/ui/app-render.helpers.node.test.ts | 1 + ui/src/ui/app-render.helpers.ts | 1 + ui/src/ui/controllers/sessions.ts | 1 + 11 files changed, 215 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf4124c65f..89c0b82b140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI/sessions: fire the documented `/new` command and lifecycle hooks only for explicit Control UI session creation, restoring session-memory and custom hook capture without changing SDK parent-session creates. Fixes #76957. Thanks @BunsDev. - Slack: preserve Socket Mode SDK error context and structured Slack API fields in reconnect logs, so startup failures no longer collapse to a bare `unknown error`. - iOS pairing: allow setup-code and manual `ws://` connects for private LAN and `.local` gateways while keeping Tailscale/public routes on `wss://`, and prefer explicit gateway passwords over stale bootstrap tokens in mixed-auth reconnects. Fixes #47887; carries forward #65185. Thanks @draix and @BunsDev. - Plugins/diagnostics: make source-only TypeScript package warnings actionable by explaining that missing compiled runtime output is a publisher packaging issue and pointing users to update/reinstall or disable/uninstall the plugin. Fixes #77835. Thanks @googlerest. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 327d986ea3e..92e54bdb745 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -1910,6 +1910,7 @@ public struct SessionsCreateParams: Codable, Sendable { public let label: String? public let model: String? public let parentsessionkey: String? + public let emitcommandhooks: Bool? public let task: String? public let message: String? @@ -1919,6 +1920,7 @@ public struct SessionsCreateParams: Codable, Sendable { label: String?, model: String?, parentsessionkey: String?, + emitcommandhooks: Bool?, task: String?, message: String?) { @@ -1927,6 +1929,7 @@ public struct SessionsCreateParams: Codable, Sendable { self.label = label self.model = model self.parentsessionkey = parentsessionkey + self.emitcommandhooks = emitcommandhooks self.task = task self.message = message } @@ -1937,6 +1940,7 @@ public struct SessionsCreateParams: Codable, Sendable { case label case model case parentsessionkey = "parentSessionKey" + case emitcommandhooks = "emitCommandHooks" case task case message } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 327d986ea3e..92e54bdb745 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1910,6 +1910,7 @@ public struct SessionsCreateParams: Codable, Sendable { public let label: String? public let model: String? public let parentsessionkey: String? + public let emitcommandhooks: Bool? public let task: String? public let message: String? @@ -1919,6 +1920,7 @@ public struct SessionsCreateParams: Codable, Sendable { label: String?, model: String?, parentsessionkey: String?, + emitcommandhooks: Bool?, task: String?, message: String?) { @@ -1927,6 +1929,7 @@ public struct SessionsCreateParams: Codable, Sendable { self.label = label self.model = model self.parentsessionkey = parentsessionkey + self.emitcommandhooks = emitcommandhooks self.task = task self.message = message } @@ -1937,6 +1940,7 @@ public struct SessionsCreateParams: Codable, Sendable { case label case model case parentsessionkey = "parentSessionKey" + case emitcommandhooks = "emitCommandHooks" case task case message } diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 83c31ad3758..6fa6de273a0 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -113,6 +113,7 @@ export const SessionsCreateParamsSchema = Type.Object( label: Type.Optional(SessionLabelString), model: Type.Optional(NonEmptyString), parentSessionKey: Type.Optional(NonEmptyString), + emitCommandHooks: Type.Optional(Type.Boolean()), task: Type.Optional(Type.String()), message: Type.Optional(Type.String()), }, diff --git a/src/gateway/server-methods/sessions.runtime.ts b/src/gateway/server-methods/sessions.runtime.ts index 7497756308f..97d3ba09f5a 100644 --- a/src/gateway/server-methods/sessions.runtime.ts +++ b/src/gateway/server-methods/sessions.runtime.ts @@ -1,7 +1,9 @@ export { archiveSessionTranscriptsForSessionDetailed, cleanupSessionBeforeMutation, + emitGatewayBeforeResetPluginHook, emitGatewaySessionEndPluginHook, + emitGatewaySessionStartPluginHook, emitSessionUnboundLifecycleEvent, performGatewaySessionReset, } from "../session-reset-service.js"; diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 3d66d4ee026..02c5008c48e 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -24,6 +24,7 @@ import { } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { + createInternalHookEvent, hasInternalHookListeners, triggerInternalHook, type SessionPatchHookContext, @@ -999,6 +1000,36 @@ export const sessionsHandlers: GatewayRequestHandlers = { } canonicalParentSessionKey = parent.canonicalKey; } + if (canonicalParentSessionKey && p.emitCommandHooks === true) { + const { entry: parentEntry } = loadSessionEntry(canonicalParentSessionKey); + const parentAgentId = normalizeAgentId( + resolveAgentIdFromSessionKey(canonicalParentSessionKey) ?? resolveDefaultAgentId(cfg), + ); + const workspaceDir = resolveAgentWorkspaceDir(cfg, parentAgentId); + if (hasInternalHookListeners("command", "new")) { + const hookEvent = createInternalHookEvent("command", "new", canonicalParentSessionKey, { + sessionEntry: parentEntry, + previousSessionEntry: parentEntry, + commandSource: "webchat", + cfg, + workspaceDir, + }); + await triggerInternalHook(hookEvent); + } + const parentTarget = resolveGatewaySessionStoreTarget({ + cfg, + key: canonicalParentSessionKey, + }); + const { emitGatewayBeforeResetPluginHook } = await loadSessionsRuntimeModule(); + await emitGatewayBeforeResetPluginHook({ + cfg, + key: canonicalParentSessionKey, + target: parentTarget, + storePath: parentTarget.storePath, + entry: parentEntry, + reason: "new", + }); + } const loweredRequestedKey = normalizeOptionalLowercaseString(requestedKey); const key = requestedKey ? loweredRequestedKey === "global" || loweredRequestedKey === "unknown" @@ -1142,6 +1173,32 @@ export const sessionsHandlers: GatewayRequestHandlers = { reason: "send", }); } + if (canonicalParentSessionKey && p.emitCommandHooks === true) { + const { entry: parentEntry } = loadSessionEntry(canonicalParentSessionKey); + const parentTarget = resolveGatewaySessionStoreTarget({ + cfg, + key: canonicalParentSessionKey, + }); + const { emitGatewaySessionEndPluginHook, emitGatewaySessionStartPluginHook } = + await loadSessionsRuntimeModule(); + emitGatewaySessionEndPluginHook({ + cfg, + sessionKey: canonicalParentSessionKey, + sessionId: parentEntry?.sessionId, + storePath: parentTarget.storePath, + sessionFile: parentEntry?.sessionFile, + agentId: parentTarget.agentId, + reason: "new", + nextSessionId: createdEntry.sessionId, + nextSessionKey: target.canonicalKey, + }); + emitGatewaySessionStartPluginHook({ + cfg, + sessionKey: target.canonicalKey, + sessionId: createdEntry.sessionId, + resumedFrom: parentEntry?.sessionId, + }); + } }, "sessions.compaction.branch": async ({ params, respond, context }) => { if ( diff --git a/src/gateway/server.sessions.reset-hooks.test.ts b/src/gateway/server.sessions.reset-hooks.test.ts index f0623e988da..5f9c00cdef7 100644 --- a/src/gateway/server.sessions.reset-hooks.test.ts +++ b/src/gateway/server.sessions.reset-hooks.test.ts @@ -296,3 +296,145 @@ test("sessions.reset emits before_reset for the entry actually reset in the writ sessionId: "sess-new", }); }); + +test("sessions.create with emitCommandHooks=true fires command:new hook against parent (#76957)", async () => { + const { dir } = await createSessionStoreDir(); + await writeSingleLineSession(dir, "sess-parent", "hello from parent"); + + await writeSessionStore({ + entries: { + main: sessionStoreEntry("sess-parent"), + }, + }); + + const result = await directSessionReq<{ ok: boolean; key: string }>("sessions.create", { + parentSessionKey: "main", + emitCommandHooks: true, + }); + expect(result.ok).toBe(true); + + const commandNewEvents = ( + sessionHookMocks.triggerInternalHook.mock.calls as unknown as Array<[unknown]> + ) + .map((call) => call[0]) + .filter( + (event): event is { type: string; action: string; context?: { commandSource?: string } } => + Boolean(event) && + typeof event === "object" && + (event as { type?: unknown }).type === "command" && + (event as { action?: unknown }).action === "new", + ); + expect(commandNewEvents).toHaveLength(1); + expect(commandNewEvents[0]).toMatchObject({ + type: "command", + action: "new", + context: { commandSource: "webchat" }, + }); +}); + +test("sessions.create with emitCommandHooks=true emits reset lifecycle hooks against parent (#76957)", async () => { + const { dir } = await createSessionStoreDir(); + const transcriptPath = path.join(dir, "sess-parent-hooks.jsonl"); + await fs.writeFile( + transcriptPath, + `${JSON.stringify({ + type: "message", + id: "m1", + message: { role: "user", content: "remember this before new" }, + })}\n`, + "utf-8", + ); + + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-parent-hooks", + sessionFile: transcriptPath, + updatedAt: Date.now(), + }, + }, + }); + + beforeResetHookState.hasBeforeResetHook = true; + + const result = await directSessionReq<{ ok: boolean; key: string }>("sessions.create", { + parentSessionKey: "main", + emitCommandHooks: true, + }); + expect(result.ok).toBe(true); + + expect(beforeResetHookMocks.runBeforeReset).toHaveBeenCalledTimes(1); + const [beforeResetEvent, beforeResetContext] = ( + beforeResetHookMocks.runBeforeReset.mock.calls as unknown as Array<[unknown, unknown]> + )[0] ?? [undefined, undefined]; + expect(beforeResetEvent).toMatchObject({ + sessionFile: transcriptPath, + reason: "new", + messages: [ + { + role: "user", + content: "remember this before new", + }, + ], + }); + expect(beforeResetContext).toMatchObject({ + agentId: "main", + sessionKey: "agent:main:main", + sessionId: "sess-parent-hooks", + }); + + 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-hooks", + sessionKey: "agent:main:main", + reason: "new", + nextSessionId: (startEvent as { sessionId?: string } | undefined)?.sessionId, + nextSessionKey: (startEvent as { sessionKey?: string } | undefined)?.sessionKey, + }); + expect(startEvent).toMatchObject({ + resumedFrom: "sess-parent-hooks", + }); + expect((startEvent as { sessionId?: string } | undefined)?.sessionId).toEqual(expect.any(String)); + expect((startEvent as { sessionKey?: string } | undefined)?.sessionKey).toMatch( + /^agent:main:dashboard:/, + ); +}); + +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"); + + await writeSessionStore({ + entries: { + main: sessionStoreEntry("sess-parent2"), + }, + }); + + const result = await directSessionReq<{ ok: boolean; key: string }>("sessions.create", { + parentSessionKey: "main", + }); + expect(result.ok).toBe(true); + + const commandNewEvents = ( + sessionHookMocks.triggerInternalHook.mock.calls as unknown as Array<[unknown]> + ) + .map((call) => call[0]) + .filter( + (event): event is { type: string; action: string } => + Boolean(event) && + typeof event === "object" && + (event as { type?: unknown }).type === "command" && + (event as { action?: unknown }).action === "new", + ); + expect(commandNewEvents).toHaveLength(0); + expect(beforeResetHookMocks.runBeforeReset).not.toHaveBeenCalled(); + expect(sessionLifecycleHookMocks.runSessionEnd).not.toHaveBeenCalled(); + expect(sessionLifecycleHookMocks.runSessionStart).not.toHaveBeenCalled(); +}); diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 12933483e98..d53b31491ba 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -433,7 +433,7 @@ export async function cleanupSessionBeforeMutation(params: { }); } -async function emitGatewayBeforeResetPluginHook(params: { +export async function emitGatewayBeforeResetPluginHook(params: { cfg: OpenClawConfig; key: string; target: ReturnType; diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index d1fb6e24b4a..38b385cafac 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -663,6 +663,7 @@ describe("createChatSession", () => { { agentId: "ops", parentSessionKey: "agent:ops:main", + emitCommandHooks: true, }, { activeMinutes: 0, diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 853dc790245..1e554a2c00a 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -637,6 +637,7 @@ export async function createChatSession(state: AppViewState) { { agentId: resolveAgentIdFromSessionKey(previousSessionKey), parentSessionKey, + emitCommandHooks: parentSessionKey !== undefined ? true : undefined, }, { activeMinutes: 0, diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 3a5aa1b1985..7bb71de2b9c 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -44,6 +44,7 @@ type CreateSessionParams = { label?: string; model?: string; parentSessionKey?: string; + emitCommandHooks?: boolean; }; type CreateSessionResult = {