feat(session): hard-delete JSONL transcript on conversation delete (#986)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -33,7 +33,7 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
||||
- **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state).
|
||||
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
|
||||
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
|
||||
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
|
||||
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records. Note: "Delete conversation" in the sidebar is a hard delete — the Main process unlinks `<id>.jsonl` plus any leftover `<id>.deleted.jsonl` and `<id>.jsonl.reset.*` siblings, *and* OpenClaw's trajectory artefacts (`<id>.trajectory.jsonl` flight recorder + `<id>.trajectory-path.json` pointer); when the pointer references a runtime file outside the agent's `sessions/` folder (the `OPENCLAW_TRAJECTORY_DIR` override), that off-disk file is unlinked too. Deleted conversations stop contributing to this chart — use a fresh session if you want history retained.
|
||||
- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries.
|
||||
- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly.
|
||||
- **UI change validation**: Any user-visible UI change should include or update an Electron E2E spec in the same PR so the interaction is covered by Playwright.
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { join } from 'node:path';
|
||||
import { getOpenClawConfigDir } from '../../utils/paths';
|
||||
import {
|
||||
removeSessionEntry,
|
||||
resolveSessionTranscriptPath,
|
||||
sweepSessionArtefacts,
|
||||
} from '../../utils/session-files';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
@@ -49,6 +55,13 @@ export async function handleSessionRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/sessions/delete — HTTP mirror of the `session:delete` IPC.
|
||||
// Both surfaces share electron/utils/session-files.ts so they sweep the
|
||||
// same set of artefacts: the live transcript, legacy `.deleted.jsonl`,
|
||||
// `.jsonl.reset.*` snapshots, the trajectory sidecar pair
|
||||
// (`<id>.trajectory.jsonl` + `<id>.trajectory-path.json`) and — when the
|
||||
// pointer points outside sessions/ (the OPENCLAW_TRAJECTORY_DIR case) —
|
||||
// the off-disk runtime trajectory it references.
|
||||
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ sessionKey: string }>(req);
|
||||
@@ -63,66 +76,39 @@ export async function handleSessionRoutes(
|
||||
return true;
|
||||
}
|
||||
const agentId = parts[1];
|
||||
// Defence-in-depth: agentId becomes a path segment under
|
||||
// ~/.openclaw/agents/. The sibling /api/sessions/transcript route
|
||||
// applies the same check to its sessionId; mirror it here so a
|
||||
// malformed key can never steer the unlink loop into another folder.
|
||||
if (!SAFE_SESSION_SEGMENT.test(agentId)) {
|
||||
sendJson(res, 400, { success: false, error: `Invalid agentId: ${agentId}` });
|
||||
return true;
|
||||
}
|
||||
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
|
||||
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
|
||||
const fsP = await import('node:fs/promises');
|
||||
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
let uuidFileName: string | undefined;
|
||||
let resolvedSrcPath: string | undefined;
|
||||
if (Array.isArray(sessionsJson.sessions)) {
|
||||
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
|
||||
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
|
||||
if (entry) {
|
||||
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (!uuidFileName && typeof entry.id === 'string') {
|
||||
uuidFileName = `${entry.id}.jsonl`;
|
||||
}
|
||||
const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey);
|
||||
if (!resolution.ok) {
|
||||
if (resolution.failure.kind === 'not-found') {
|
||||
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
|
||||
} else {
|
||||
logger.warn(`[api/sessions/delete] Refusing out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`);
|
||||
sendJson(res, 400, { success: false, error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}` });
|
||||
}
|
||||
}
|
||||
if (!uuidFileName && sessionsJson[sessionKey] != null) {
|
||||
const val = sessionsJson[sessionKey];
|
||||
if (typeof val === 'string') {
|
||||
uuidFileName = val;
|
||||
} else if (typeof val === 'object' && val !== null) {
|
||||
const entry = val as Record<string, unknown>;
|
||||
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (absFile) {
|
||||
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
|
||||
resolvedSrcPath = absFile;
|
||||
} else {
|
||||
uuidFileName = absFile;
|
||||
}
|
||||
} else {
|
||||
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
|
||||
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!uuidFileName && !resolvedSrcPath) {
|
||||
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
|
||||
return true;
|
||||
}
|
||||
if (!resolvedSrcPath) {
|
||||
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
|
||||
resolvedSrcPath = join(sessionsDir, uuidFileName!);
|
||||
}
|
||||
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
|
||||
try {
|
||||
await fsP.access(resolvedSrcPath);
|
||||
await fsP.rename(resolvedSrcPath, dstPath);
|
||||
} catch {
|
||||
// Non-fatal; still try to update sessions.json.
|
||||
|
||||
const sweep = await sweepSessionArtefacts(resolution.sessionsDirAbs, resolution.baseId);
|
||||
for (const { path: failedPath, error } of sweep.errors) {
|
||||
logger.warn(`[api/sessions/delete] Failed to unlink ${failedPath}: ${String(error)}`);
|
||||
}
|
||||
|
||||
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||
const json2 = JSON.parse(raw2) as Record<string, unknown>;
|
||||
if (Array.isArray(json2.sessions)) {
|
||||
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
|
||||
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
|
||||
} else if (json2[sessionKey]) {
|
||||
delete json2[sessionKey];
|
||||
}
|
||||
removeSessionEntry(json2, sessionKey);
|
||||
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,6 +24,11 @@ import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui';
|
||||
import { logger } from '../utils/logger';
|
||||
import { resolveAgentIdFromChannel } from '../utils/agent-config';
|
||||
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
|
||||
import {
|
||||
removeSessionEntry,
|
||||
resolveSessionTranscriptPath,
|
||||
sweepSessionArtefacts,
|
||||
} from '../utils/session-files';
|
||||
import {
|
||||
saveChannelConfig,
|
||||
getChannelConfig,
|
||||
@@ -2586,11 +2591,31 @@ async function resolveOutgoingMediaUrl(
|
||||
/**
|
||||
* Session IPC handlers
|
||||
*
|
||||
* Performs a soft-delete of a session's JSONL transcript on disk.
|
||||
* Performs a HARD delete of a session's JSONL transcript on disk.
|
||||
* sessionKey format: "agent:<agentId>:<suffix>" — e.g. "agent:main:session-1234567890".
|
||||
* The JSONL file lives at: ~/.openclaw/agents/<agentId>/sessions/<suffix>.jsonl
|
||||
* Renaming to <suffix>.deleted.jsonl hides it from sessions.list.
|
||||
* The JSONL file lives at: ~/.openclaw/agents/<agentId>/sessions/<id>.jsonl
|
||||
* (where <id> is typically a UUID resolved via sessions.json).
|
||||
*
|
||||
* For each deleted session we unlink every file that belongs to its on-disk id:
|
||||
* - <id>.jsonl — the live transcript
|
||||
* - <id>.deleted.jsonl — leftovers from earlier soft-delete releases
|
||||
* - <id>.jsonl.reset.* — historical snapshots produced by sessions.reset
|
||||
* - <id>.trajectory.jsonl — OpenClaw runtime "flight recorder" sidecar
|
||||
* - <id>.trajectory-path.json — pointer to the runtime trajectory; if it
|
||||
* points outside the sessions/ folder
|
||||
* (OPENCLAW_TRAJECTORY_DIR override) the
|
||||
* referenced file is unlinked too.
|
||||
*
|
||||
* The session entry is also removed from sessions.json so sessions.list stops
|
||||
* surfacing it. Token-usage history reported by the Dashboard reads the same
|
||||
* transcripts, so deleted conversations stop contributing to the chart.
|
||||
*
|
||||
* Path resolution and the sibling sweep are shared with the HTTP mirror at
|
||||
* `electron/api/routes/sessions.ts` via `electron/utils/session-files.ts`,
|
||||
* so both surfaces unlink the same set of files for a given session id.
|
||||
*/
|
||||
const SAFE_AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
||||
|
||||
function registerSessionHandlers(): void {
|
||||
ipcMain.handle('session:delete', async (_, sessionKey: string) => {
|
||||
try {
|
||||
@@ -2604,6 +2629,13 @@ function registerSessionHandlers(): void {
|
||||
}
|
||||
|
||||
const agentId = parts[1];
|
||||
// Defence-in-depth: agentId becomes a path segment under
|
||||
// ~/.openclaw/agents/. Reject anything that could escape that root
|
||||
// (".." segments, slashes, NULs, etc.) before touching the FS.
|
||||
if (!SAFE_AGENT_ID.test(agentId)) {
|
||||
return { success: false, error: `Invalid agentId: ${agentId}` };
|
||||
}
|
||||
|
||||
const openclawConfigDir = getOpenClawConfigDir();
|
||||
const sessionsDir = join(openclawConfigDir, 'agents', agentId, 'sessions');
|
||||
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
|
||||
@@ -2623,94 +2655,41 @@ function registerSessionHandlers(): void {
|
||||
return { success: false, error: `Could not read sessions.json: ${String(e)}` };
|
||||
}
|
||||
|
||||
// sessions.json structure: try common shapes used by OpenClaw Gateway:
|
||||
// Shape A (array): { sessions: [{ key, file, ... }] }
|
||||
// Shape B (object): { [sessionKey]: { file, ... } }
|
||||
// Shape C (array): { sessions: [{ key, id, ... }] } — id is the UUID
|
||||
let uuidFileName: string | undefined;
|
||||
|
||||
// Shape A / C — array under "sessions" key
|
||||
if (Array.isArray(sessionsJson.sessions)) {
|
||||
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
|
||||
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
|
||||
if (entry) {
|
||||
// Could be "file", "fileName", "id" + ".jsonl", or "path"
|
||||
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (!uuidFileName && typeof entry.id === 'string') {
|
||||
uuidFileName = `${entry.id}.jsonl`;
|
||||
}
|
||||
const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey);
|
||||
if (!resolution.ok) {
|
||||
if (resolution.failure.kind === 'not-found') {
|
||||
const rawVal = sessionsJson[sessionKey];
|
||||
logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(rawVal)}`);
|
||||
return { success: false, error: `Cannot resolve file for session: ${sessionKey}` };
|
||||
}
|
||||
logger.warn(`[session:delete] Refusing to delete out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`);
|
||||
return { success: false, error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}` };
|
||||
}
|
||||
|
||||
// Shape B — flat object keyed by sessionKey; value may be a string or an object.
|
||||
// Actual Gateway format: { sessionFile: "/abs/path/uuid.jsonl", sessionId: "uuid", ... }
|
||||
let resolvedSrcPath: string | undefined;
|
||||
|
||||
if (!uuidFileName && sessionsJson[sessionKey] != null) {
|
||||
const val = sessionsJson[sessionKey];
|
||||
if (typeof val === 'string') {
|
||||
uuidFileName = val;
|
||||
} else if (typeof val === 'object' && val !== null) {
|
||||
const entry = val as Record<string, unknown>;
|
||||
// Priority: absolute sessionFile path > relative file/fileName/path > id/sessionId as UUID
|
||||
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (absFile) {
|
||||
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
|
||||
// Absolute path — use directly
|
||||
resolvedSrcPath = absFile;
|
||||
} else {
|
||||
uuidFileName = absFile;
|
||||
}
|
||||
} else {
|
||||
// Fall back to UUID fields
|
||||
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
|
||||
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!uuidFileName && !resolvedSrcPath) {
|
||||
const rawVal = sessionsJson[sessionKey];
|
||||
logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(rawVal)}`);
|
||||
return { success: false, error: `Cannot resolve file for session: ${sessionKey}` };
|
||||
}
|
||||
|
||||
// Normalise: if we got a relative filename, resolve it against sessionsDir
|
||||
if (!resolvedSrcPath) {
|
||||
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
|
||||
resolvedSrcPath = join(sessionsDir, uuidFileName!);
|
||||
}
|
||||
|
||||
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
|
||||
const { resolvedSrcPath, sessionsDirAbs, baseId } = resolution;
|
||||
logger.info(`[session:delete] file: ${resolvedSrcPath}`);
|
||||
|
||||
// ── Step 2: rename the JSONL file ──
|
||||
try {
|
||||
await fsP.access(resolvedSrcPath);
|
||||
await fsP.rename(resolvedSrcPath, dstPath);
|
||||
logger.info(`[session:delete] Renamed ${resolvedSrcPath} → ${dstPath}`);
|
||||
} catch (e) {
|
||||
logger.warn(`[session:delete] Could not rename file: ${String(e)}`);
|
||||
// ── Step 2: hard-delete the JSONL transcript and its siblings ──
|
||||
const sweep = await sweepSessionArtefacts(sessionsDirAbs, baseId);
|
||||
for (const removedPath of sweep.removed) {
|
||||
logger.info(`[session:delete] Unlinked ${removedPath}`);
|
||||
}
|
||||
for (const { path: failedPath, error } of sweep.errors) {
|
||||
logger.warn(`[session:delete] Failed to unlink ${failedPath}: ${String(error)}`);
|
||||
}
|
||||
logger.info(`[session:delete] Hard-deleted ${sweep.removed.length} file(s) for ${baseId}`);
|
||||
|
||||
// ── Step 3: remove the entry from sessions.json ──
|
||||
try {
|
||||
// Re-read to avoid race conditions
|
||||
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||
const json2 = JSON.parse(raw2) as Record<string, unknown>;
|
||||
|
||||
if (Array.isArray(json2.sessions)) {
|
||||
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
|
||||
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
|
||||
} else if (json2[sessionKey]) {
|
||||
delete json2[sessionKey];
|
||||
}
|
||||
|
||||
removeSessionEntry(json2, sessionKey);
|
||||
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
|
||||
logger.info(`[session:delete] Removed "${sessionKey}" from sessions.json`);
|
||||
} catch (e) {
|
||||
logger.warn(`[session:delete] Could not update sessions.json: ${String(e)}`);
|
||||
// Non-fatal — JSONL rename already done
|
||||
// Non-fatal — transcript files were already unlinked.
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
271
electron/utils/session-files.ts
Normal file
271
electron/utils/session-files.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Session file helpers
|
||||
*
|
||||
* Shared between the Main IPC `session:delete` handler and the HTTP mirror
|
||||
* `POST /api/sessions/delete`. Both surfaces must agree on:
|
||||
*
|
||||
* 1. how a `sessionKey` (e.g. `agent:main:session-1234`) is resolved to an
|
||||
* on-disk transcript path via `sessions.json`, and
|
||||
* 2. which sibling artefacts are swept when the user hard-deletes a
|
||||
* conversation:
|
||||
* - `<baseId>.jsonl` the live transcript
|
||||
* - `<baseId>.deleted.jsonl` legacy soft-delete leftover
|
||||
* - `<baseId>.jsonl.reset.*` reset snapshots from sessions.reset
|
||||
* - `<baseId>.trajectory.jsonl` OpenClaw runtime "flight recorder"
|
||||
* (default location, beside session)
|
||||
* - `<baseId>.trajectory-path.json` pointer sidecar; when present we
|
||||
* follow `runtimeFile` so the actual
|
||||
* trajectory still gets unlinked even
|
||||
* if `OPENCLAW_TRAJECTORY_DIR` moved
|
||||
* it out of the sessions/ folder
|
||||
*
|
||||
* The helper enforces that the resolved session path lives inside the
|
||||
* agent's `sessions/` directory so a corrupt or malicious `sessions.json`
|
||||
* can never steer the unlink loop into an unrelated folder. The pointer-
|
||||
* follow path is the only deliberate exception: it walks to whatever
|
||||
* `runtimeFile` says (defended by schema + extension checks) so ClawX can
|
||||
* cooperate with OpenClaw's documented `OPENCLAW_TRAJECTORY_DIR` override.
|
||||
*/
|
||||
|
||||
import { promises as fsP } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export type SessionResolutionFailure =
|
||||
| { kind: 'not-found' }
|
||||
| { kind: 'path-outside-scope'; resolvedPath: string };
|
||||
|
||||
export type SessionResolutionResult =
|
||||
| { ok: true; resolvedSrcPath: string; sessionsDirAbs: string; baseId: string }
|
||||
| { ok: false; failure: SessionResolutionFailure };
|
||||
|
||||
/**
|
||||
* `path.isAbsolute` only respects the *current* platform's rules. OpenClaw
|
||||
* may be running in a context (or have been migrated from a profile) where
|
||||
* the on-disk `sessionFile` uses Windows-style backslash paths, Windows
|
||||
* forward-slash paths (`C:/...`) or POSIX paths. Accepting all three keeps
|
||||
* the IPC handler portable across Win32 + macOS + Linux installs.
|
||||
*/
|
||||
function isAnyAbsolute(candidate: string): boolean {
|
||||
return path.isAbsolute(candidate) || path.win32.isAbsolute(candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute on-disk transcript path for `sessionKey` from a parsed
|
||||
* `sessions.json` blob. Returns `{ ok: false, failure: 'not-found' }` when no
|
||||
* matching entry is found and `{ ok: false, failure: 'path-outside-scope' }`
|
||||
* when the resolved path escapes `sessionsDir` (defence-in-depth).
|
||||
*
|
||||
* Supports the three sessions.json shapes observed in the wild:
|
||||
* A) `{ sessions: [{ key, file, ... }] }` (array, file field)
|
||||
* B) `{ [sessionKey]: { sessionFile|file|...|id } }` (object-keyed, current)
|
||||
* C) `{ sessions: [{ key, id, ... }] }` (array, id field only)
|
||||
*/
|
||||
export function resolveSessionTranscriptPath(
|
||||
sessionsJson: Record<string, unknown>,
|
||||
sessionsDir: string,
|
||||
sessionKey: string,
|
||||
): SessionResolutionResult {
|
||||
let uuidFileName: string | undefined;
|
||||
let resolvedSrcPath: string | undefined;
|
||||
|
||||
if (Array.isArray(sessionsJson.sessions)) {
|
||||
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
|
||||
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
|
||||
if (entry) {
|
||||
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (!uuidFileName && typeof entry.id === 'string') {
|
||||
uuidFileName = `${entry.id}.jsonl`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!uuidFileName && sessionsJson[sessionKey] != null) {
|
||||
const val = sessionsJson[sessionKey];
|
||||
if (typeof val === 'string') {
|
||||
uuidFileName = val;
|
||||
} else if (typeof val === 'object' && val !== null) {
|
||||
const entry = val as Record<string, unknown>;
|
||||
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (absFile) {
|
||||
if (isAnyAbsolute(absFile)) {
|
||||
resolvedSrcPath = absFile;
|
||||
} else {
|
||||
uuidFileName = absFile;
|
||||
}
|
||||
} else {
|
||||
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
|
||||
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!uuidFileName && !resolvedSrcPath) {
|
||||
return { ok: false, failure: { kind: 'not-found' } };
|
||||
}
|
||||
|
||||
if (!resolvedSrcPath) {
|
||||
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
|
||||
resolvedSrcPath = path.join(sessionsDir, uuidFileName!);
|
||||
}
|
||||
|
||||
const sessionsDirAbs = path.dirname(resolvedSrcPath);
|
||||
// sessionsDir is always built from `getOpenClawConfigDir()` + the validated
|
||||
// agentId, so anything that doesn't resolve underneath it is suspect. We
|
||||
// refuse rather than try to "normalise" the entry — the worst case for the
|
||||
// user is that the sidebar entry stops listing it; the worst case if we
|
||||
// proceeded is `unlink`s in someone else's directory.
|
||||
const rel = path.relative(sessionsDir, sessionsDirAbs);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
return { ok: false, failure: { kind: 'path-outside-scope', resolvedPath: resolvedSrcPath } };
|
||||
}
|
||||
|
||||
const baseId = path.basename(resolvedSrcPath).replace(/\.jsonl$/, '');
|
||||
return { ok: true, resolvedSrcPath, sessionsDirAbs, baseId };
|
||||
}
|
||||
|
||||
export interface SweepResult {
|
||||
removed: string[];
|
||||
errors: Array<{ path: string; error: NodeJS.ErrnoException }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema marker written by OpenClaw's `writeTrajectoryPointerBestEffort`.
|
||||
* Any pointer file that doesn't carry this exact `traceSchema` is treated
|
||||
* as untrusted and ignored — we only follow pointers we know OpenClaw
|
||||
* authored.
|
||||
*/
|
||||
const TRAJECTORY_POINTER_SCHEMA = 'openclaw-trajectory-pointer';
|
||||
|
||||
/**
|
||||
* Best-effort: parse the trajectory pointer sidecar and return the off-disk
|
||||
* runtime file path it points at. Returns null when the pointer is absent,
|
||||
* malformed, missing the openclaw-trajectory-pointer schema, or its
|
||||
* `runtimeFile` is not an absolute `.jsonl` path. Any of those conditions
|
||||
* means we silently skip the off-disk unlink — the local sidecar sweep
|
||||
* still runs and the worst case is one orphaned file the user can clean up
|
||||
* manually.
|
||||
*/
|
||||
async function readTrajectoryRuntimeFile(pointerPath: string): Promise<string | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fsP.readFile(pointerPath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.traceSchema !== TRAJECTORY_POINTER_SCHEMA) return null;
|
||||
const runtimeFile = obj.runtimeFile;
|
||||
if (typeof runtimeFile !== 'string' || runtimeFile.length === 0) return null;
|
||||
// OpenClaw always writes a `.jsonl` path; refusing anything else keeps us
|
||||
// from being weaponised into deleting (say) `/etc/passwd` if a hostile
|
||||
// sessions.json/pointer combination ever showed up on disk.
|
||||
if (!runtimeFile.endsWith('.jsonl')) return null;
|
||||
if (!path.isAbsolute(runtimeFile) && !path.win32.isAbsolute(runtimeFile)) return null;
|
||||
return runtimeFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete every artefact that belongs to `baseId` inside
|
||||
* `sessionsDirAbs`. ENOENT is tolerated for both the directory itself and
|
||||
* each individual file — by the time the sweep runs we've already committed
|
||||
* to the deletion, so missing files just mean a previous sweep got there
|
||||
* first. Other errors are accumulated so the caller can log/surface them.
|
||||
*
|
||||
* In addition to the local sidecars (`.jsonl`, `.deleted.jsonl`,
|
||||
* `.jsonl.reset.*`, `.trajectory.jsonl`, `.trajectory-path.json`), the
|
||||
* sweep follows the `.trajectory-path.json` pointer when it exists and
|
||||
* unlinks the off-disk runtime file at `runtimeFile`. This keeps ClawX in
|
||||
* sync with OpenClaw's `OPENCLAW_TRAJECTORY_DIR` override (where the
|
||||
* actual trajectory is stored outside the sessions/ folder).
|
||||
*/
|
||||
export async function sweepSessionArtefacts(
|
||||
sessionsDirAbs: string,
|
||||
baseId: string,
|
||||
): Promise<SweepResult> {
|
||||
const result: SweepResult = { removed: [], errors: [] };
|
||||
|
||||
let dirEntries: string[];
|
||||
try {
|
||||
dirEntries = await fsP.readdir(sessionsDirAbs);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') return result;
|
||||
result.errors.push({ path: sessionsDirAbs, error: err as NodeJS.ErrnoException });
|
||||
return result;
|
||||
}
|
||||
|
||||
const pointerName = `${baseId}.trajectory-path.json`;
|
||||
const targets = dirEntries.filter((name) =>
|
||||
name === `${baseId}.jsonl`
|
||||
|| name === `${baseId}.deleted.jsonl`
|
||||
|| name === `${baseId}.trajectory.jsonl`
|
||||
|| name === pointerName
|
||||
|| name.startsWith(`${baseId}.jsonl.reset.`),
|
||||
);
|
||||
|
||||
// Follow the pointer FIRST so the off-disk runtime file (lives outside
|
||||
// sessionsDirAbs when OPENCLAW_TRAJECTORY_DIR is set) gets unlinked
|
||||
// before we delete its only on-disk reference. If the pointer is gone,
|
||||
// malformed, or carries an in-sessions-dir path, this is a no-op and the
|
||||
// standard local sweep below covers everything.
|
||||
if (targets.includes(pointerName)) {
|
||||
const pointerPath = path.join(sessionsDirAbs, pointerName);
|
||||
const runtimeFile = await readTrajectoryRuntimeFile(pointerPath);
|
||||
if (runtimeFile) {
|
||||
const absRuntime = path.resolve(runtimeFile);
|
||||
const rel = path.relative(sessionsDirAbs, absRuntime);
|
||||
// Only chase pointers that escape the sessions dir. Anything pointing
|
||||
// back inside is already covered by the local-target sweep below
|
||||
// (and would risk a double-unlink / double-error otherwise).
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
try {
|
||||
await fsP.unlink(absRuntime);
|
||||
result.removed.push(absRuntime);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') {
|
||||
result.errors.push({ path: absRuntime, error: err as NodeJS.ErrnoException });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(targets.map(async (name) => {
|
||||
const target = path.join(sessionsDirAbs, name);
|
||||
try {
|
||||
await fsP.unlink(target);
|
||||
result.removed.push(target);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') return;
|
||||
result.errors.push({ path: target, error: err as NodeJS.ErrnoException });
|
||||
}
|
||||
}));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `sessionKey` from a parsed sessions.json object (mutates in place).
|
||||
* Handles both the array-shape (`{ sessions: [...] }`) and the object-keyed
|
||||
* shape so the IPC handler and HTTP route share one rewrite path.
|
||||
*/
|
||||
export function removeSessionEntry(
|
||||
sessionsJson: Record<string, unknown>,
|
||||
sessionKey: string,
|
||||
): void {
|
||||
if (Array.isArray(sessionsJson.sessions)) {
|
||||
sessionsJson.sessions = (sessionsJson.sessions as Array<Record<string, unknown>>)
|
||||
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
|
||||
} else if (sessionsJson[sessionKey]) {
|
||||
delete sessionsJson[sessionKey];
|
||||
}
|
||||
}
|
||||
71
harness/specs/tasks/hard-delete-session-jsonl.md
Normal file
71
harness/specs/tasks/hard-delete-session-jsonl.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
id: hard-delete-session-jsonl
|
||||
title: Hard-delete session JSONL on conversation delete
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Remove the on-disk session transcript (and its sibling artefacts) when the user deletes a conversation, instead of soft-deleting it via rename.
|
||||
touchedAreas:
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/api/routes/sessions.ts
|
||||
- electron/utils/session-files.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat.ts
|
||||
- tests/unit/session-delete-route.test.ts
|
||||
- harness/specs/tasks/hard-delete-session-jsonl.md
|
||||
- AGENTS.md
|
||||
expectedUserBehavior:
|
||||
- Confirming "Delete" in the sidebar conversation menu removes <id>.jsonl, <id>.deleted.jsonl and any <id>.jsonl.reset.* siblings from the agent's sessions folder.
|
||||
- OpenClaw's trajectory artefacts for the same session id are removed too — both the local <id>.trajectory.jsonl flight recorder and the <id>.trajectory-path.json pointer sidecar.
|
||||
- When the pointer's runtimeFile points outside the sessions/ folder (the OPENCLAW_TRAJECTORY_DIR override), that off-disk runtime file is also unlinked so no orphan trajectory remains anywhere on disk.
|
||||
- The session entry is removed from sessions.json so OpenClaw sessions.list stops returning it.
|
||||
- The sidebar list, sessionLabels and sessionLastActivity for the deleted key are cleared in the renderer store.
|
||||
- Token usage history reported by the Dashboard stops including the deleted session.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/session-delete-route.test.ts
|
||||
- tests/unit/chat-session-actions.test.ts
|
||||
acceptance:
|
||||
- Renderer continues to use src/lib/host-api.ts and src/lib/api-client.ts; no new direct ipcRenderer or Gateway HTTP calls.
|
||||
- IPC channel name session:delete and HTTP route POST /api/sessions/delete are unchanged in shape.
|
||||
- Both the IPC handler in electron/main/ipc-handlers.ts and the HTTP mirror in electron/api/routes/sessions.ts unlink the same set of files for a given session id, sharing electron/utils/session-files.ts so the disk contract cannot drift.
|
||||
- The handler tolerates ENOENT (file already gone) and still updates sessions.json so the sidebar stops listing the entry.
|
||||
- agentId from the sessionKey is validated against /^[A-Za-z0-9][A-Za-z0-9_-]*$/ in both surfaces and any sessionFile resolved to a path outside the agent sessions/ directory is refused (defence-in-depth against a corrupt sessions.json).
|
||||
- Absolute-path detection accepts POSIX paths, Windows backslash paths (C:\...) and Windows forward-slash paths (C:/...) so the sweep works on every supported OS.
|
||||
- The sweep also unlinks <id>.trajectory.jsonl and <id>.trajectory-path.json sidecars produced by OpenClaw's runtime trajectory writer.
|
||||
- When <id>.trajectory-path.json carries the openclaw-trajectory-pointer schema and an absolute .jsonl runtimeFile that lives outside the sessions/ folder, the off-disk runtime trajectory is unlinked too (covers the OPENCLAW_TRAJECTORY_DIR override). Pointers with the wrong schema, a non-.jsonl runtimeFile, or a non-absolute runtimeFile are ignored and no extra files are touched.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
Conversation deletion in ClawX runs entirely on the Main process — the
|
||||
OpenClaw Gateway does not expose a `sessions.delete` RPC. Historically the
|
||||
operation was a soft delete: the live `<id>.jsonl` transcript was renamed to
|
||||
`<id>.deleted.jsonl` so `sessions.list` would skip it. This task replaces
|
||||
that rename with a true `unlink` plus a sibling sweep that also removes
|
||||
`<id>.deleted.jsonl` (legacy soft-delete leftovers) and `<id>.jsonl.reset.*`
|
||||
(reset snapshots produced by `sessions.reset`).
|
||||
|
||||
Both backends (the IPC handler used by the refactored chat store and the
|
||||
HTTP route used by the legacy chat store) share the same disk contract via
|
||||
`electron/utils/session-files.ts`, which centralises:
|
||||
|
||||
- sessions.json entry resolution across the three observed shapes,
|
||||
- cross-platform absolute-path detection (POSIX, Windows `C:\...` and
|
||||
Windows `C:/...`) using `path.isAbsolute` + `path.win32.isAbsolute`,
|
||||
- a defence-in-depth scope check that refuses any `sessionFile` whose
|
||||
resolved directory escapes the agent's `sessions/` folder,
|
||||
- the sibling sweep (`.jsonl`, `.deleted.jsonl`, `.jsonl.reset.*`,
|
||||
`.trajectory.jsonl`, `.trajectory-path.json`) with ENOENT tolerance, and
|
||||
- the trajectory pointer-follow that handles OpenClaw's
|
||||
`OPENCLAW_TRAJECTORY_DIR` override: when the pointer is well-formed
|
||||
(`traceSchema === "openclaw-trajectory-pointer"` and `runtimeFile` is
|
||||
an absolute `.jsonl` outside sessions/), the off-disk runtime file is
|
||||
unlinked too. Malformed or unsafe pointers are ignored.
|
||||
|
||||
Renderer surface is untouched: the confirm dialog in `Sidebar.tsx`, the
|
||||
`useChatStore.deleteSession` API, and the host-api/api-client boundary all
|
||||
continue to work without changes. The only user-visible effect is that
|
||||
deleted conversations no longer leave hidden `.deleted.jsonl` files behind
|
||||
and no longer contribute to Dashboard token-usage history.
|
||||
@@ -290,7 +290,10 @@ export function Sidebar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="no-drag h-8 w-8 shrink-0 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10"
|
||||
className={cn(
|
||||
'no-drag h-8 w-8 shrink-0 rounded-lg text-foreground/80',
|
||||
'hover:bg-black/5 hover:text-foreground/80 dark:hover:bg-white/5',
|
||||
)}
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
@@ -304,6 +307,7 @@ export function Sidebar() {
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-col gap-0 px-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-new-chat"
|
||||
onClick={() => {
|
||||
const { messages } = useChatStore.getState();
|
||||
@@ -311,8 +315,8 @@ export function Sidebar() {
|
||||
navigate('/');
|
||||
}}
|
||||
className={cn(
|
||||
'sidebar-nav-text mb-1 flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
|
||||
'border border-transparent text-foreground/80 shadow-none hover:bg-black/5 hover:text-foreground dark:hover:bg-white/5',
|
||||
'sidebar-nav-text flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
|
||||
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
|
||||
sidebarCollapsed && 'justify-center px-0',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -989,7 +989,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
setSkillPickerOpen(false);
|
||||
setModelPickerOpen((open) => !open);
|
||||
}}
|
||||
disabled={sending || !currentAgent || !!switchingModelRef}
|
||||
disabled={inputDisabled || sending || !currentAgent || !!switchingModelRef}
|
||||
title={t('composer.pickModel')}
|
||||
>
|
||||
{switchingModelRef ? (
|
||||
|
||||
@@ -1676,16 +1676,17 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
//
|
||||
// NOTE: The OpenClaw Gateway does NOT expose a sessions.delete (or equivalent)
|
||||
// RPC — confirmed by inspecting client.ts, protocol.ts and the full codebase.
|
||||
// Deletion is therefore a local-only UI operation: the session is removed from
|
||||
// the sidebar list and its labels/activity maps are cleared. The underlying
|
||||
// JSONL history file on disk is intentionally left intact, consistent with the
|
||||
// newSession() design that avoids sessions.reset to preserve history.
|
||||
// Deletion is therefore performed locally: the renderer drops the session
|
||||
// from the sidebar / labels / activity maps and the Main process hard-deletes
|
||||
// the on-disk transcript so it stops appearing in sessions.list and stops
|
||||
// contributing to the Dashboard token-usage history.
|
||||
|
||||
deleteSession: async (key: string) => {
|
||||
clearCachedSessionHistory(key);
|
||||
// Soft-delete the session's JSONL transcript on disk.
|
||||
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
|
||||
// sessions.list skips it automatically.
|
||||
// Hard-delete the session's JSONL transcript on disk.
|
||||
// The main process unlinks <id>.jsonl plus any leftover
|
||||
// <id>.deleted.jsonl and <id>.jsonl.reset.* siblings, then removes the
|
||||
// entry from sessions.json so sessions.list stops surfacing it.
|
||||
try {
|
||||
const result = await hostApiFetch<{
|
||||
success: boolean;
|
||||
|
||||
@@ -200,15 +200,16 @@ export function createSessionActions(
|
||||
//
|
||||
// NOTE: The OpenClaw Gateway does NOT expose a sessions.delete (or equivalent)
|
||||
// RPC — confirmed by inspecting client.ts, protocol.ts and the full codebase.
|
||||
// Deletion is therefore a local-only UI operation: the session is removed from
|
||||
// the sidebar list and its labels/activity maps are cleared. The underlying
|
||||
// JSONL history file on disk is intentionally left intact, consistent with the
|
||||
// newSession() design that avoids sessions.reset to preserve history.
|
||||
// Deletion is therefore performed locally: the renderer drops the session
|
||||
// from the sidebar / labels / activity maps and the Main process hard-deletes
|
||||
// the on-disk transcript so it stops appearing in sessions.list and stops
|
||||
// contributing to the Dashboard token-usage history.
|
||||
|
||||
deleteSession: async (key: string) => {
|
||||
// Soft-delete the session's JSONL transcript on disk.
|
||||
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
|
||||
// sessions.list skips it automatically.
|
||||
// Hard-delete the session's JSONL transcript on disk.
|
||||
// The main process unlinks <id>.jsonl plus any leftover
|
||||
// <id>.deleted.jsonl and <id>.jsonl.reset.* siblings, then removes the
|
||||
// entry from sessions.json so sessions.list stops surfacing it.
|
||||
try {
|
||||
const result = await invokeIpc('session:delete', key) as {
|
||||
success: boolean;
|
||||
|
||||
@@ -229,10 +229,45 @@ describe('ChatInput agent targeting', () => {
|
||||
channelTypes: [],
|
||||
},
|
||||
];
|
||||
agentsState.defaultModelRef = 'custom-aaaaaaaa/gpt-a';
|
||||
const now = '2025-01-01T00:00:00.000Z';
|
||||
providersState.accounts = [
|
||||
{
|
||||
id: 'aaaaaaaa',
|
||||
vendorId: 'custom',
|
||||
label: 'Alpha',
|
||||
authMode: 'api_key',
|
||||
baseUrl: 'http://127.0.0.1:1/v1',
|
||||
model: 'custom-aaaaaaaa/gpt-a',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'bbbbbbbb',
|
||||
vendorId: 'custom',
|
||||
label: 'Beta',
|
||||
authMode: 'api_key',
|
||||
baseUrl: 'http://127.0.0.1:2/v1',
|
||||
model: 'custom-bbbbbbbb/gpt-b',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
providersState.statuses = [
|
||||
{ id: 'aaaaaaaa', name: 'Alpha', type: 'custom', hasKey: true, keyMasked: 'sk-***', enabled: true, createdAt: now, updatedAt: now },
|
||||
{ id: 'bbbbbbbb', name: 'Beta', type: 'custom', hasKey: true, keyMasked: 'sk-***', enabled: true, createdAt: now, updatedAt: now },
|
||||
];
|
||||
providersState.defaultAccountId = 'aaaaaaaa';
|
||||
|
||||
renderChatInput();
|
||||
|
||||
expect(screen.getByTestId('chat-composer-input')).toBeDisabled();
|
||||
expect(screen.getByTestId('chat-composer-skill')).toBeDisabled();
|
||||
expect(screen.getByTestId('chat-model-picker-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows starting status while gateway is running but not yet ready', () => {
|
||||
|
||||
454
tests/unit/session-delete-route.test.ts
Normal file
454
tests/unit/session-delete-route.test.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* Unit tests for the /api/sessions/delete HTTP route.
|
||||
*
|
||||
* The route hard-deletes a conversation's transcript on disk:
|
||||
* - <id>.jsonl — the live transcript
|
||||
* - <id>.deleted.jsonl — leftovers from earlier soft-delete releases
|
||||
* - <id>.jsonl.reset.* — reset snapshots from sessions.reset
|
||||
* It also removes the entry from sessions.json.
|
||||
*
|
||||
* These tests exercise the real `handleSessionRoutes` against a temp
|
||||
* OpenClaw config directory so the FS contract is verified end-to-end.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const sendJsonMock = vi.fn();
|
||||
const parseJsonBodyMock = vi.fn();
|
||||
|
||||
const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'session-delete-route-openclaw');
|
||||
|
||||
vi.mock('@electron/api/route-utils', () => ({
|
||||
parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args),
|
||||
sendJson: (...args: unknown[]) => sendJsonMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/paths', () => ({
|
||||
getOpenClawConfigDir: () => testOpenClawConfigDir,
|
||||
getOpenClawDir: () => testOpenClawConfigDir,
|
||||
getOpenClawResolvedDir: () => testOpenClawConfigDir,
|
||||
}));
|
||||
|
||||
const AGENT_ID = 'main';
|
||||
const SESSIONS_DIR = join(testOpenClawConfigDir, 'agents', AGENT_ID, 'sessions');
|
||||
const SESSIONS_JSON = join(SESSIONS_DIR, 'sessions.json');
|
||||
|
||||
function seedSessionsDir(): void {
|
||||
rmSync(testOpenClawConfigDir, { recursive: true, force: true });
|
||||
mkdirSync(SESSIONS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function writeSessionsJson(payload: Record<string, unknown>): void {
|
||||
writeFileSync(SESSIONS_JSON, JSON.stringify(payload, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function makeReq(method = 'POST'): IncomingMessage {
|
||||
return { method } as IncomingMessage;
|
||||
}
|
||||
|
||||
function makeRes(): ServerResponse {
|
||||
return {
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn(),
|
||||
} as unknown as ServerResponse;
|
||||
}
|
||||
|
||||
const DELETE_URL = new URL('http://127.0.0.1:13210/api/sessions/delete');
|
||||
const ctx = {} as never;
|
||||
|
||||
describe('handleSessionRoutes — POST /api/sessions/delete', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
seedSessionsDir();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(testOpenClawConfigDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('hard-deletes the live <id>.jsonl and clears the entry from sessions.json', async () => {
|
||||
const sessionKey = 'agent:main:session-aaa';
|
||||
const fileName = 'aaa-uuid.jsonl';
|
||||
writeFileSync(join(SESSIONS_DIR, fileName), 'message\n', 'utf8');
|
||||
writeSessionsJson({
|
||||
[sessionKey]: { sessionFile: join(SESSIONS_DIR, fileName), sessionId: 'aaa-uuid' },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
const handled = await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true });
|
||||
expect(existsSync(join(SESSIONS_DIR, fileName))).toBe(false);
|
||||
const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8'));
|
||||
expect(updated[sessionKey]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('also removes a leftover <id>.deleted.jsonl from a prior soft-delete release', async () => {
|
||||
const sessionKey = 'agent:main:session-bbb';
|
||||
const baseId = 'bbb-uuid';
|
||||
writeFileSync(join(SESSIONS_DIR, `${baseId}.jsonl`), '', 'utf8');
|
||||
writeFileSync(join(SESSIONS_DIR, `${baseId}.deleted.jsonl`), '', 'utf8');
|
||||
writeSessionsJson({
|
||||
[sessionKey]: { sessionFile: join(SESSIONS_DIR, `${baseId}.jsonl`), sessionId: baseId },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(existsSync(join(SESSIONS_DIR, `${baseId}.jsonl`))).toBe(false);
|
||||
expect(existsSync(join(SESSIONS_DIR, `${baseId}.deleted.jsonl`))).toBe(false);
|
||||
});
|
||||
|
||||
it('removes every <id>.jsonl.reset.* sibling that belongs to the same session id', async () => {
|
||||
const sessionKey = 'agent:main:session-ccc';
|
||||
const baseId = 'ccc-uuid';
|
||||
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
|
||||
const reset1 = join(SESSIONS_DIR, `${baseId}.jsonl.reset.2026-04-01T00-00-00.000Z`);
|
||||
const reset2 = join(SESSIONS_DIR, `${baseId}.jsonl.reset.2026-04-02T00-00-00.000Z`);
|
||||
writeFileSync(liveFile, '', 'utf8');
|
||||
writeFileSync(reset1, '', 'utf8');
|
||||
writeFileSync(reset2, '', 'utf8');
|
||||
writeSessionsJson({
|
||||
[sessionKey]: { sessionFile: liveFile, sessionId: baseId },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(existsSync(liveFile)).toBe(false);
|
||||
expect(existsSync(reset1)).toBe(false);
|
||||
expect(existsSync(reset2)).toBe(false);
|
||||
});
|
||||
|
||||
it('still succeeds and updates sessions.json when the transcript is already gone', async () => {
|
||||
const sessionKey = 'agent:main:session-ddd';
|
||||
const baseId = 'ddd-uuid';
|
||||
// No transcript file on disk — only sessions.json knows about it.
|
||||
writeSessionsJson({
|
||||
[sessionKey]: { sessionFile: join(SESSIONS_DIR, `${baseId}.jsonl`), sessionId: baseId },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true });
|
||||
const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8'));
|
||||
expect(updated[sessionKey]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects sessionKeys that are not agent-scoped with 400', async () => {
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: 'main' });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
400,
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects agentIds that contain path-traversal segments with 400', async () => {
|
||||
// Even if the caller manages to put `..` into the agent slot, the route
|
||||
// must refuse before any FS access happens — otherwise sessionsDir would
|
||||
// resolve outside ~/.openclaw/agents/.
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: 'agent:..:foo' });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
400,
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to unlink files when sessionFile points outside the agent sessions dir', async () => {
|
||||
// Defence-in-depth: if a corrupt sessions.json claims the transcript
|
||||
// lives in /tmp (or anywhere outside the agent sessions folder), the
|
||||
// sweep must not run there and existing files must survive untouched.
|
||||
const sessionKey = 'agent:main:session-escape';
|
||||
const escapeDir = join(testOpenClawConfigDir, 'unrelated-dir');
|
||||
mkdirSync(escapeDir, { recursive: true });
|
||||
const escapeFile = join(escapeDir, 'escape-uuid.jsonl');
|
||||
writeFileSync(escapeFile, 'must-not-be-deleted', 'utf8');
|
||||
writeSessionsJson({
|
||||
[sessionKey]: { sessionFile: escapeFile, sessionId: 'escape-uuid' },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
400,
|
||||
expect.objectContaining({ success: false }),
|
||||
);
|
||||
expect(existsSync(escapeFile)).toBe(true);
|
||||
expect(readFileSync(escapeFile, 'utf8')).toBe('must-not-be-deleted');
|
||||
// sessions.json is left intact when the resolution fails — the entry
|
||||
// stays so a follow-up fix can be applied without losing track of it.
|
||||
const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8'));
|
||||
expect(updated[sessionKey]).toBeDefined();
|
||||
});
|
||||
|
||||
it("also sweeps OpenClaw's trajectory sidecars (<id>.trajectory.jsonl + <id>.trajectory-path.json)", async () => {
|
||||
// OpenClaw writes `<base>.trajectory.jsonl` (flight recorder) and
|
||||
// `<base>.trajectory-path.json` (pointer) next to the session file.
|
||||
// Hard-deleting the conversation must leave neither behind, otherwise
|
||||
// the next `sessions.list` is clean but the agent's sessions/ folder
|
||||
// accumulates orphaned trajectory data.
|
||||
const sessionKey = 'agent:main:session-traj';
|
||||
const baseId = 'traj-uuid';
|
||||
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
|
||||
const trajFile = join(SESSIONS_DIR, `${baseId}.trajectory.jsonl`);
|
||||
const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`);
|
||||
writeFileSync(liveFile, '', 'utf8');
|
||||
writeFileSync(trajFile, '{"event":"session.started"}\n', 'utf8');
|
||||
writeFileSync(
|
||||
pointerFile,
|
||||
JSON.stringify({
|
||||
traceSchema: 'openclaw-trajectory-pointer',
|
||||
schemaVersion: 1,
|
||||
sessionId: baseId,
|
||||
runtimeFile: trajFile,
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
writeSessionsJson({
|
||||
[sessionKey]: { sessionFile: liveFile, sessionId: baseId },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(existsSync(liveFile)).toBe(false);
|
||||
expect(existsSync(trajFile)).toBe(false);
|
||||
expect(existsSync(pointerFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('follows the trajectory pointer to unlink an OPENCLAW_TRAJECTORY_DIR-style off-sessions runtime file', async () => {
|
||||
// When OPENCLAW_TRAJECTORY_DIR is set, the pointer's `runtimeFile`
|
||||
// resolves outside sessions/. Without the pointer-follow, that file
|
||||
// would be orphaned forever after deletion.
|
||||
const sessionKey = 'agent:main:session-trajdir';
|
||||
const baseId = 'trajdir-uuid';
|
||||
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
|
||||
const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`);
|
||||
const trajectoryDir = join(testOpenClawConfigDir, 'trajectory-dir');
|
||||
mkdirSync(trajectoryDir, { recursive: true });
|
||||
const offDiskRuntime = join(trajectoryDir, `${baseId}.jsonl`);
|
||||
writeFileSync(liveFile, '', 'utf8');
|
||||
writeFileSync(offDiskRuntime, '{"event":"prompt.submitted"}\n', 'utf8');
|
||||
writeFileSync(
|
||||
pointerFile,
|
||||
JSON.stringify({
|
||||
traceSchema: 'openclaw-trajectory-pointer',
|
||||
schemaVersion: 1,
|
||||
sessionId: baseId,
|
||||
runtimeFile: offDiskRuntime,
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
writeSessionsJson({
|
||||
[sessionKey]: { sessionFile: liveFile, sessionId: baseId },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(existsSync(liveFile)).toBe(false);
|
||||
expect(existsSync(pointerFile)).toBe(false);
|
||||
expect(existsSync(offDiskRuntime)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to follow a pointer whose runtimeFile is not an absolute .jsonl path', async () => {
|
||||
// Defence-in-depth: a malformed/hostile pointer must NOT get us to
|
||||
// unlink arbitrary files (e.g. /etc/passwd or relative paths).
|
||||
const sessionKey = 'agent:main:session-evilpointer';
|
||||
const baseId = 'evilpointer-uuid';
|
||||
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
|
||||
const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`);
|
||||
const bystander = join(testOpenClawConfigDir, 'bystander-dir', 'must-not-touch.txt');
|
||||
mkdirSync(join(testOpenClawConfigDir, 'bystander-dir'), { recursive: true });
|
||||
writeFileSync(bystander, 'kept', 'utf8');
|
||||
writeFileSync(liveFile, '', 'utf8');
|
||||
writeFileSync(
|
||||
pointerFile,
|
||||
JSON.stringify({
|
||||
traceSchema: 'openclaw-trajectory-pointer',
|
||||
schemaVersion: 1,
|
||||
sessionId: baseId,
|
||||
// Wrong extension on purpose — not a `.jsonl`.
|
||||
runtimeFile: bystander,
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
writeSessionsJson({
|
||||
[sessionKey]: { sessionFile: liveFile, sessionId: baseId },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
// The session and the (correctly-detected) pointer are gone, but the
|
||||
// bystander file the pointer tried to escape to is untouched.
|
||||
expect(existsSync(liveFile)).toBe(false);
|
||||
expect(existsSync(pointerFile)).toBe(false);
|
||||
expect(existsSync(bystander)).toBe(true);
|
||||
expect(readFileSync(bystander, 'utf8')).toBe('kept');
|
||||
});
|
||||
|
||||
it("tolerates a malformed pointer (still cleans local sidecars, doesn't fail the whole delete)", async () => {
|
||||
const sessionKey = 'agent:main:session-malformedptr';
|
||||
const baseId = 'malformedptr-uuid';
|
||||
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
|
||||
const trajFile = join(SESSIONS_DIR, `${baseId}.trajectory.jsonl`);
|
||||
const pointerFile = join(SESSIONS_DIR, `${baseId}.trajectory-path.json`);
|
||||
writeFileSync(liveFile, '', 'utf8');
|
||||
writeFileSync(trajFile, '', 'utf8');
|
||||
// Garbage JSON — the sweep must not blow up.
|
||||
writeFileSync(pointerFile, '{ not-json', 'utf8');
|
||||
writeSessionsJson({
|
||||
[sessionKey]: { sessionFile: liveFile, sessionId: baseId },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true });
|
||||
expect(existsSync(liveFile)).toBe(false);
|
||||
expect(existsSync(trajFile)).toBe(false);
|
||||
expect(existsSync(pointerFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not touch another session's trajectory sidecars during the sweep", async () => {
|
||||
const targetKey = 'agent:main:session-trajiso-target';
|
||||
const survivorKey = 'agent:main:session-trajiso-keep';
|
||||
const targetBase = 'trajiso-target';
|
||||
const survivorBase = 'trajiso-keep';
|
||||
const targetFile = join(SESSIONS_DIR, `${targetBase}.jsonl`);
|
||||
const targetTraj = join(SESSIONS_DIR, `${targetBase}.trajectory.jsonl`);
|
||||
const survivorFile = join(SESSIONS_DIR, `${survivorBase}.jsonl`);
|
||||
const survivorTraj = join(SESSIONS_DIR, `${survivorBase}.trajectory.jsonl`);
|
||||
const survivorPointer = join(SESSIONS_DIR, `${survivorBase}.trajectory-path.json`);
|
||||
writeFileSync(targetFile, '', 'utf8');
|
||||
writeFileSync(targetTraj, '', 'utf8');
|
||||
writeFileSync(survivorFile, 'kept', 'utf8');
|
||||
writeFileSync(survivorTraj, 'kept', 'utf8');
|
||||
writeFileSync(survivorPointer, JSON.stringify({
|
||||
traceSchema: 'openclaw-trajectory-pointer',
|
||||
schemaVersion: 1,
|
||||
sessionId: survivorBase,
|
||||
runtimeFile: survivorTraj,
|
||||
}), 'utf8');
|
||||
writeSessionsJson({
|
||||
[targetKey]: { sessionFile: targetFile, sessionId: targetBase },
|
||||
[survivorKey]: { sessionFile: survivorFile, sessionId: survivorBase },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: targetKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(existsSync(targetFile)).toBe(false);
|
||||
expect(existsSync(targetTraj)).toBe(false);
|
||||
expect(existsSync(survivorFile)).toBe(true);
|
||||
expect(existsSync(survivorTraj)).toBe(true);
|
||||
expect(existsSync(survivorPointer)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats Windows forward-slash absolute paths as absolute (cross-platform)', async () => {
|
||||
// OpenClaw on Windows can write `sessionFile` as either `C:\…` (back-
|
||||
// slash) or `C:/…` (forward-slash). Node's `path.win32.isAbsolute`
|
||||
// accepts both; the resolver must too. We can't actually create a
|
||||
// `C:/…` path on POSIX, so we cover the same code path with a Windows-
|
||||
// style absolute that points back into our temp sessions dir using
|
||||
// mixed slashes. The detector should still treat it as absolute and
|
||||
// route through the in-scope sibling sweep.
|
||||
const sessionKey = 'agent:main:session-win';
|
||||
const baseId = 'win-uuid';
|
||||
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
|
||||
writeFileSync(liveFile, '', 'utf8');
|
||||
// Force forward slashes — historically this would have been classed as
|
||||
// a *relative* filename on POSIX and `join`ed onto sessionsDir, which
|
||||
// produced a junk path that no `readdir` could find.
|
||||
const forwardSlashAbs = liveFile.replace(/\\/g, '/');
|
||||
writeSessionsJson({
|
||||
[sessionKey]: { sessionFile: forwardSlashAbs, sessionId: baseId },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true });
|
||||
expect(existsSync(liveFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not touch other sessions in the same directory', async () => {
|
||||
const targetKey = 'agent:main:session-eee';
|
||||
const survivorKey = 'agent:main:session-fff';
|
||||
const targetBase = 'eee-uuid';
|
||||
const survivorBase = 'fff-uuid';
|
||||
const targetFile = join(SESSIONS_DIR, `${targetBase}.jsonl`);
|
||||
const survivorFile = join(SESSIONS_DIR, `${survivorBase}.jsonl`);
|
||||
writeFileSync(targetFile, '', 'utf8');
|
||||
writeFileSync(survivorFile, 'kept', 'utf8');
|
||||
writeSessionsJson({
|
||||
[targetKey]: { sessionFile: targetFile, sessionId: targetBase },
|
||||
[survivorKey]: { sessionFile: survivorFile, sessionId: survivorBase },
|
||||
});
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey: targetKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(existsSync(targetFile)).toBe(false);
|
||||
expect(existsSync(survivorFile)).toBe(true);
|
||||
expect(readFileSync(survivorFile, 'utf8')).toBe('kept');
|
||||
const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8'));
|
||||
expect(updated[targetKey]).toBeUndefined();
|
||||
expect(updated[survivorKey]).toBeDefined();
|
||||
});
|
||||
|
||||
it('also supports the array-shape sessions.json (sessions[] with id field)', async () => {
|
||||
const sessionKey = 'agent:main:session-ggg';
|
||||
const baseId = 'ggg-uuid';
|
||||
const liveFile = join(SESSIONS_DIR, `${baseId}.jsonl`);
|
||||
writeFileSync(liveFile, '', 'utf8');
|
||||
writeSessionsJson({
|
||||
sessions: [
|
||||
{ key: sessionKey, id: baseId },
|
||||
{ key: 'agent:main:keep', id: 'keep-uuid' },
|
||||
],
|
||||
});
|
||||
writeFileSync(join(SESSIONS_DIR, 'keep-uuid.jsonl'), 'kept', 'utf8');
|
||||
parseJsonBodyMock.mockResolvedValueOnce({ sessionKey });
|
||||
|
||||
const { handleSessionRoutes } = await import('@electron/api/routes/sessions');
|
||||
await handleSessionRoutes(makeReq(), makeRes(), DELETE_URL, ctx);
|
||||
|
||||
expect(existsSync(liveFile)).toBe(false);
|
||||
expect(existsSync(join(SESSIONS_DIR, 'keep-uuid.jsonl'))).toBe(true);
|
||||
const updated = JSON.parse(readFileSync(SESSIONS_JSON, 'utf8')) as { sessions: Array<{ key: string }> };
|
||||
expect(updated.sessions.find((s) => s.key === sessionKey)).toBeUndefined();
|
||||
expect(updated.sessions.find((s) => s.key === 'agent:main:keep')).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user