fix(sessions): restore Control UI new hooks

This commit is contained in:
Val Alexander
2026-05-05 21:28:24 -05:00
parent b9f711089a
commit 705ea32ab7
11 changed files with 215 additions and 1 deletions

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()),
},

View File

@@ -1,7 +1,9 @@
export {
archiveSessionTranscriptsForSessionDetailed,
cleanupSessionBeforeMutation,
emitGatewayBeforeResetPluginHook,
emitGatewaySessionEndPluginHook,
emitGatewaySessionStartPluginHook,
emitSessionUnboundLifecycleEvent,
performGatewaySessionReset,
} from "../session-reset-service.js";

View File

@@ -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 (

View File

@@ -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();
});

View File

@@ -433,7 +433,7 @@ export async function cleanupSessionBeforeMutation(params: {
});
}
async function emitGatewayBeforeResetPluginHook(params: {
export async function emitGatewayBeforeResetPluginHook(params: {
cfg: OpenClawConfig;
key: string;
target: ReturnType<typeof resolveGatewaySessionStoreTarget>;

View File

@@ -663,6 +663,7 @@ describe("createChatSession", () => {
{
agentId: "ops",
parentSessionKey: "agent:ops:main",
emitCommandHooks: true,
},
{
activeMinutes: 0,

View File

@@ -637,6 +637,7 @@ export async function createChatSession(state: AppViewState) {
{
agentId: resolveAgentIdFromSessionKey(previousSessionKey),
parentSessionKey,
emitCommandHooks: parentSessionKey !== undefined ? true : undefined,
},
{
activeMinutes: 0,

View File

@@ -44,6 +44,7 @@ type CreateSessionParams = {
label?: string;
model?: string;
parentSessionKey?: string;
emitCommandHooks?: boolean;
};
type CreateSessionResult = {