mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix(sessions): restore Control UI /new hooks
Fixes #76957.
Restores the Control UI /new hook lifecycle through an explicit sessions.create emitCommandHooks opt-in, preserving hook-free defaults for programmatic parent-session creates.
Validation:
- pnpm protocol:check
- pnpm test src/gateway/server.sessions.reset-hooks.test.ts ui/src/ui/app-render.helpers.node.test.ts
- pnpm exec oxlint on touched TS files
- pnpm exec oxfmt --check --threads=1 on touched files
- git diff --check
- OPENCLAW_LOCAL_CHECK=1 OPENCLAW_LOCAL_CHECK_MODE=throttled env NODE_OPTIONS=--max-old-space-size=4096 pnpm check:changed
- GitHub PR checks green on 3a446ec78e
- ClawSweeper re-review completed with no blocking findings and security cleared
Duplicate triage:
- #77376, #77004, and #76967 were superseded closed attempts for #76957
- #77562 is a closed duplicate issue
- #77880 mentions #76957 but is not a duplicate of this hook fix
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export {
|
||||
archiveSessionTranscriptsForSessionDetailed,
|
||||
cleanupSessionBeforeMutation,
|
||||
emitGatewayBeforeResetPluginHook,
|
||||
emitGatewaySessionEndPluginHook,
|
||||
emitGatewaySessionStartPluginHook,
|
||||
emitSessionUnboundLifecycleEvent,
|
||||
performGatewaySessionReset,
|
||||
} from "../session-reset-service.js";
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -663,6 +663,7 @@ describe("createChatSession", () => {
|
||||
{
|
||||
agentId: "ops",
|
||||
parentSessionKey: "agent:ops:main",
|
||||
emitCommandHooks: true,
|
||||
},
|
||||
{
|
||||
activeMinutes: 0,
|
||||
|
||||
@@ -637,6 +637,7 @@ export async function createChatSession(state: AppViewState) {
|
||||
{
|
||||
agentId: resolveAgentIdFromSessionKey(previousSessionKey),
|
||||
parentSessionKey,
|
||||
emitCommandHooks: parentSessionKey !== undefined ? true : undefined,
|
||||
},
|
||||
{
|
||||
activeMinutes: 0,
|
||||
|
||||
@@ -44,6 +44,7 @@ type CreateSessionParams = {
|
||||
label?: string;
|
||||
model?: string;
|
||||
parentSessionKey?: string;
|
||||
emitCommandHooks?: boolean;
|
||||
};
|
||||
|
||||
type CreateSessionResult = {
|
||||
|
||||
Reference in New Issue
Block a user