TUI/Gateway: emit internal hooks for /new and /reset

This commit is contained in:
Vignesh Natarajan
2026-02-14 16:33:19 -08:00
parent 301b3ff912
commit b08146fad6
7 changed files with 115 additions and 4 deletions

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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<typeof import("../auto-reply/reply/queue.js")>(
"../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<typeof import("../hooks/internal-hooks.js")>(
"../hooks/internal-hooks.js",
);
return {
...actual,
triggerInternalHook: sessionHookMocks.triggerInternalHook,
};
});
installGatewayTestHooks({ scope: "suite" });
let server: Awaited<ReturnType<typeof startGatewayServer>>;
@@ -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");

View File

@@ -204,8 +204,11 @@ export class GatewayChatClient {
return await this.client.request<SessionsPatchResult>("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() {

View File

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

View File

@@ -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) {