fix(session-memory): harden reset transcript recovery
This commit is contained in:
@@ -17,8 +17,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/Process: keep staged process-tree termination safe by skipping delayed Windows force-kill when the PID is already gone, and align PTY force-kill wait behavior with actual exit timing to avoid premature "killed" state reporting during grace periods. (#18626)
|
||||
- Slack: limit forwarded-attachment extraction to explicit shared-message attachments and skip non-Slack forwarded image URLs, preventing non-forward unfurls from polluting inbound agent context. Also adds regression tests for forwarded vs non-forward attachment handling.
|
||||
- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088)
|
||||
- Slack: restrict forwarded-attachment ingestion to explicit shared-message attachments and skip non-Slack forwarded `image_url` fetches, preventing non-forward attachment unfurls from polluting inbound agent context while preserving forwarded message handling.
|
||||
- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07.
|
||||
- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky.
|
||||
- iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae.
|
||||
|
||||
@@ -2,8 +2,8 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js";
|
||||
import type { HookHandler } from "../../hooks.js";
|
||||
import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js";
|
||||
import { createHookEvent } from "../../hooks.js";
|
||||
|
||||
// Avoid calling the embedded Pi agent (global command lane); keep this unit test deterministic.
|
||||
@@ -300,6 +300,138 @@ describe("session-memory hook", () => {
|
||||
expect(memoryContent).toContain("assistant: Recovered from reset fallback");
|
||||
});
|
||||
|
||||
it("handles reset-path session pointers from previousSessionEntry", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
|
||||
const sessionsDir = path.join(tempDir, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
const sessionId = "reset-pointer-session";
|
||||
const resetSessionFile = await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: `${sessionId}.jsonl.reset.2026-02-16T22-26-33.000Z`,
|
||||
content: createMockSessionContent([
|
||||
{ role: "user", content: "Message from reset pointer" },
|
||||
{ role: "assistant", content: "Recovered directly from reset file" },
|
||||
]),
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: tempDir } },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const event = createHookEvent("command", "new", "agent:main:main", {
|
||||
cfg,
|
||||
previousSessionEntry: {
|
||||
sessionId,
|
||||
sessionFile: resetSessionFile,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(event);
|
||||
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
const files = await fs.readdir(memoryDir);
|
||||
expect(files.length).toBe(1);
|
||||
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]), "utf-8");
|
||||
|
||||
expect(memoryContent).toContain("user: Message from reset pointer");
|
||||
expect(memoryContent).toContain("assistant: Recovered directly from reset file");
|
||||
});
|
||||
|
||||
it("recovers transcript when previousSessionEntry.sessionFile is missing", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
|
||||
const sessionsDir = path.join(tempDir, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
const sessionId = "missing-session-file";
|
||||
await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: `${sessionId}.jsonl`,
|
||||
content: "",
|
||||
});
|
||||
await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: `${sessionId}.jsonl.reset.2026-02-16T22-26-33.000Z`,
|
||||
content: createMockSessionContent([
|
||||
{ role: "user", content: "Recovered with missing sessionFile pointer" },
|
||||
{ role: "assistant", content: "Recovered by sessionId fallback" },
|
||||
]),
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: tempDir } },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const event = createHookEvent("command", "new", "agent:main:main", {
|
||||
cfg,
|
||||
previousSessionEntry: {
|
||||
sessionId,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(event);
|
||||
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
const files = await fs.readdir(memoryDir);
|
||||
expect(files.length).toBe(1);
|
||||
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]), "utf-8");
|
||||
|
||||
expect(memoryContent).toContain("user: Recovered with missing sessionFile pointer");
|
||||
expect(memoryContent).toContain("assistant: Recovered by sessionId fallback");
|
||||
});
|
||||
|
||||
it("prefers the newest reset transcript when multiple reset candidates exist", async () => {
|
||||
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
|
||||
const sessionsDir = path.join(tempDir, "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
const activeSessionFile = await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: "test-session.jsonl",
|
||||
content: "",
|
||||
});
|
||||
|
||||
await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: "test-session.jsonl.reset.2026-02-16T22-26-33.000Z",
|
||||
content: createMockSessionContent([
|
||||
{ role: "user", content: "Older rotated transcript" },
|
||||
{ role: "assistant", content: "Old summary" },
|
||||
]),
|
||||
});
|
||||
await writeWorkspaceFile({
|
||||
dir: sessionsDir,
|
||||
name: "test-session.jsonl.reset.2026-02-16T22-26-34.000Z",
|
||||
content: createMockSessionContent([
|
||||
{ role: "user", content: "Newest rotated transcript" },
|
||||
{ role: "assistant", content: "Newest summary" },
|
||||
]),
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: tempDir } },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const event = createHookEvent("command", "new", "agent:main:main", {
|
||||
cfg,
|
||||
previousSessionEntry: {
|
||||
sessionId: "test-123",
|
||||
sessionFile: activeSessionFile,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(event);
|
||||
|
||||
const memoryDir = path.join(tempDir, "memory");
|
||||
const files = await fs.readdir(memoryDir);
|
||||
expect(files.length).toBe(1);
|
||||
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]), "utf-8");
|
||||
|
||||
expect(memoryContent).toContain("user: Newest rotated transcript");
|
||||
expect(memoryContent).toContain("assistant: Newest summary");
|
||||
expect(memoryContent).not.toContain("Older rotated transcript");
|
||||
});
|
||||
|
||||
it("handles empty session files gracefully", async () => {
|
||||
// Should not throw
|
||||
const { files } = await runNewWithPreviousSession({ sessionContent: "" });
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { HookHandler } from "../../hooks.js";
|
||||
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
|
||||
import { resolveStateDir } from "../../../config/paths.js";
|
||||
import { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
|
||||
import { hasInterSessionUserProvenance } from "../../../sessions/input-provenance.js";
|
||||
import { resolveHookConfig } from "../../config.js";
|
||||
import type { HookHandler } from "../../hooks.js";
|
||||
import { generateSlugViaLLM } from "../../llm-slug-generator.js";
|
||||
|
||||
const log = createSubsystemLogger("hooks/session-memory");
|
||||
@@ -107,6 +107,65 @@ async function getRecentSessionContentWithResetFallback(
|
||||
}
|
||||
}
|
||||
|
||||
function stripResetSuffix(fileName: string): string {
|
||||
const resetIndex = fileName.indexOf(".reset.");
|
||||
return resetIndex === -1 ? fileName : fileName.slice(0, resetIndex);
|
||||
}
|
||||
|
||||
async function findPreviousSessionFile(params: {
|
||||
sessionsDir: string;
|
||||
currentSessionFile?: string;
|
||||
sessionId?: string;
|
||||
}): Promise<string | undefined> {
|
||||
try {
|
||||
const files = await fs.readdir(params.sessionsDir);
|
||||
const fileSet = new Set(files);
|
||||
|
||||
const baseFromReset = params.currentSessionFile
|
||||
? stripResetSuffix(path.basename(params.currentSessionFile))
|
||||
: undefined;
|
||||
if (baseFromReset && fileSet.has(baseFromReset)) {
|
||||
return path.join(params.sessionsDir, baseFromReset);
|
||||
}
|
||||
|
||||
const trimmedSessionId = params.sessionId?.trim();
|
||||
if (trimmedSessionId) {
|
||||
const canonicalFile = `${trimmedSessionId}.jsonl`;
|
||||
if (fileSet.has(canonicalFile)) {
|
||||
return path.join(params.sessionsDir, canonicalFile);
|
||||
}
|
||||
|
||||
const topicVariants = files
|
||||
.filter(
|
||||
(name) =>
|
||||
name.startsWith(`${trimmedSessionId}-topic-`) &&
|
||||
name.endsWith(".jsonl") &&
|
||||
!name.includes(".reset."),
|
||||
)
|
||||
.toSorted()
|
||||
.toReversed();
|
||||
if (topicVariants.length > 0) {
|
||||
return path.join(params.sessionsDir, topicVariants[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.currentSessionFile) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nonResetJsonl = files
|
||||
.filter((name) => name.endsWith(".jsonl") && !name.includes(".reset."))
|
||||
.toSorted()
|
||||
.toReversed();
|
||||
if (nonResetJsonl.length > 0) {
|
||||
return path.join(params.sessionsDir, nonResetJsonl[0]);
|
||||
}
|
||||
} catch {
|
||||
// Ignore directory read errors.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session context to memory when /new command is triggered
|
||||
*/
|
||||
@@ -133,12 +192,36 @@ const saveSessionToMemory: HookHandler = async (event) => {
|
||||
const dateStr = now.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
|
||||
// Generate descriptive slug from session using LLM
|
||||
// Prefer previousSessionEntry (old session before /new) over current (which may be empty)
|
||||
const sessionEntry = (context.previousSessionEntry || context.sessionEntry || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const currentSessionId = sessionEntry.sessionId as string;
|
||||
const currentSessionFile = sessionEntry.sessionFile as string;
|
||||
let currentSessionFile = (sessionEntry.sessionFile as string) || undefined;
|
||||
|
||||
// If sessionFile is empty or looks like a new/reset file, try to find the previous session file.
|
||||
if (!currentSessionFile || currentSessionFile.includes(".reset.")) {
|
||||
const sessionsDirs = new Set<string>();
|
||||
if (currentSessionFile) {
|
||||
sessionsDirs.add(path.dirname(currentSessionFile));
|
||||
}
|
||||
sessionsDirs.add(path.join(workspaceDir, "sessions"));
|
||||
|
||||
for (const sessionsDir of sessionsDirs) {
|
||||
const recoveredSessionFile = await findPreviousSessionFile({
|
||||
sessionsDir,
|
||||
currentSessionFile,
|
||||
sessionId: currentSessionId,
|
||||
});
|
||||
if (!recoveredSessionFile) {
|
||||
continue;
|
||||
}
|
||||
currentSessionFile = recoveredSessionFile;
|
||||
log.debug("Found previous session file", { file: currentSessionFile });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Session context resolved", {
|
||||
sessionId: currentSessionId,
|
||||
|
||||
Reference in New Issue
Block a user