diff --git a/CHANGELOG.md b/CHANGELOG.md index efa48eb622e..40929460779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai - Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui. - Memory/QMD: make `memory status` read-only by skipping QMD boot update/embed side effects for status-only manager checks. - Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors. +- TUI/Hooks: pass explicit reset reason (`new` vs `reset`) through `sessions.reset` and emit internal command hooks for gateway-triggered resets so `/new` hook workflows fire in TUI/webchat. - Memory/QMD: pass result limits to `search`/`vsearch` commands so QMD can cap results earlier. - Memory/QMD: avoid reading full markdown files when a `from/lines` window is requested in QMD reads. - Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn. diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index a4363542f5a..e0d855bd4f7 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -82,7 +82,10 @@ export const SessionsPatchParamsSchema = Type.Object( ); export const SessionsResetParamsSchema = Type.Object( - { key: NonEmptyString }, + { + key: NonEmptyString, + reason: Type.Optional(Type.Union([Type.Literal("new"), Type.Literal("reset")])), + }, { additionalProperties: false }, ); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index f27c6b8e313..c10f825d45f 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -13,6 +13,7 @@ import { type SessionEntry, updateSessionStore, } from "../../config/sessions.js"; +import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; import { ErrorCodes, @@ -306,6 +307,19 @@ export const sessionsHandlers: GatewayRequestHandlers = { const cfg = loadConfig(); const target = resolveGatewaySessionStoreTarget({ cfg, key }); const { entry } = loadSessionEntry(key); + const commandReason = p.reason === "new" ? "new" : "reset"; + const hookEvent = createInternalHookEvent( + "command", + commandReason, + target.canonicalKey ?? key, + { + sessionEntry: entry, + previousSessionEntry: entry, + commandSource: "gateway:sessions.reset", + cfg, + }, + ); + await triggerInternalHook(hookEvent); const sessionId = entry?.sessionId; const cleanupError = await ensureSessionRuntimeCleanup({ cfg, key, target, sessionId }); if (cleanupError) { diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts index d6f66ef315d..9d387c8ac6e 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts @@ -21,6 +21,10 @@ const sessionCleanupMocks = vi.hoisted(() => ({ stopSubagentsForRequester: vi.fn(() => ({ stopped: 0 })), })); +const sessionHookMocks = vi.hoisted(() => ({ + triggerInternalHook: vi.fn(async () => {}), +})); + vi.mock("../auto-reply/reply/queue.js", async () => { const actual = await vi.importActual( "../auto-reply/reply/queue.js", @@ -41,6 +45,16 @@ vi.mock("../auto-reply/reply/abort.js", async () => { }; }); +vi.mock("../hooks/internal-hooks.js", async () => { + const actual = await vi.importActual( + "../hooks/internal-hooks.js", + ); + return { + ...actual, + triggerInternalHook: sessionHookMocks.triggerInternalHook, + }; +}); + installGatewayTestHooks({ scope: "suite" }); let server: Awaited>; @@ -74,6 +88,7 @@ describe("gateway server sessions", () => { beforeEach(() => { sessionCleanupMocks.clearSessionQueues.mockClear(); sessionCleanupMocks.stopSubagentsForRequester.mockClear(); + sessionHookMocks.triggerInternalHook.mockClear(); }); test("lists and patches session store via sessions.* RPC", async () => { @@ -643,6 +658,43 @@ describe("gateway server sessions", () => { ws.close(); }); + test("sessions.reset emits internal command hook with reason", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-")); + const storePath = path.join(dir, "sessions.json"); + testState.sessionStorePath = storePath; + + await fs.writeFile( + path.join(dir, "sess-main.jsonl"), + `${JSON.stringify({ role: "user", content: "hello" })}\n`, + "utf-8", + ); + + await writeSessionStore({ + entries: { + main: { sessionId: "sess-main", updatedAt: Date.now() }, + }, + }); + + const { ws } = await openClient(); + const reset = await rpcReq<{ ok: true; key: string }>(ws, "sessions.reset", { + key: "main", + reason: "new", + }); + expect(reset.ok).toBe(true); + expect(sessionHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1); + const [event] = sessionHookMocks.triggerInternalHook.mock.calls[0] ?? []; + expect(event).toMatchObject({ + type: "command", + action: "new", + sessionKey: "agent:main:main", + context: { + commandSource: "gateway:sessions.reset", + }, + }); + expect(event.context.previousSessionEntry).toMatchObject({ sessionId: "sess-main" }); + ws.close(); + }); + test("sessions.reset returns unavailable when active run does not stop", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sessions-")); const storePath = path.join(dir, "sessions.json"); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index f859fe027f4..30016edc333 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -204,8 +204,11 @@ export class GatewayChatClient { return await this.client.request("sessions.patch", opts); } - async resetSession(key: string) { - return await this.client.request("sessions.reset", { key }); + async resetSession(key: string, reason?: "new" | "reset") { + return await this.client.request("sessions.reset", { + key, + ...(reason ? { reason } : {}), + }); } async getStatus() { diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 269dc7c3820..8e9f45d6cff 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -45,4 +45,42 @@ describe("tui command handlers", () => { ); expect(requestRender).toHaveBeenCalled(); }); + + it("passes reset reason when handling /new and /reset", async () => { + const resetSession = vi.fn().mockResolvedValue({ ok: true }); + const addSystem = vi.fn(); + const requestRender = vi.fn(); + const loadHistory = vi.fn().mockResolvedValue(undefined); + + const { handleCommand } = createCommandHandlers({ + client: { resetSession } as never, + chatLog: { addSystem } as never, + tui: { requestRender } as never, + opts: {}, + state: { + currentSessionKey: "agent:main:main", + activeChatRunId: null, + sessionInfo: {}, + } as never, + deliverDefault: false, + openOverlay: vi.fn(), + closeOverlay: vi.fn(), + refreshSessionInfo: vi.fn(), + loadHistory, + setSession: vi.fn(), + refreshAgents: vi.fn(), + abortActive: vi.fn(), + setActivityStatus: vi.fn(), + formatSessionKey: vi.fn(), + applySessionInfoFromPatch: vi.fn(), + noteLocalRunId: vi.fn(), + }); + + await handleCommand("/new"); + await handleCommand("/reset"); + + expect(resetSession).toHaveBeenNthCalledWith(1, "agent:main:main", "new"); + expect(resetSession).toHaveBeenNthCalledWith(2, "agent:main:main", "reset"); + expect(loadHistory).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 8be381f6225..233cc293bdc 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -436,7 +436,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { state.sessionInfo.totalTokens = null; tui.requestRender(); - await client.resetSession(state.currentSessionKey); + await client.resetSession(state.currentSessionKey, name); chatLog.addSystem(`session ${state.currentSessionKey} reset`); await loadHistory(); } catch (err) {