diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 99a73935..8b6019d0 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -554,6 +554,7 @@ pi --session a8ec1c2a # Resume by session ID (partial UUID) In the `/resume` picker: - `Ctrl+P` toggles display of the session `.jsonl` file path - `Ctrl+D` deletes the selected session (inline confirmation; uses `trash` if available and cannot delete the active session) +- `Ctrl+R` opens `Rename Session` component, `Esc` cancels and returns to session list, `Enter` applies the new name and reloads the list. **Resuming by session ID:** The `--session` flag accepts a session UUID (or prefix). Session IDs are visible in filenames under `~/.pi/agent/sessions//` (e.g., `2025-12-13T17-47-46-817Z_a8ec1c2a-5a5f-4699-88cb-03e7d3cb9292.jsonl`). The UUID is the part after the underscore. You can also search by session ID in the `pi -r` picker. diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 3ca22355..e2e8d0f1 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -39,6 +39,7 @@ export async function selectSession( process.exit(0); }, () => ui.requestRender(), + { showRenameHint: false }, ); ui.addChild(selector); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 488600ae..dbb54f89 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -498,6 +498,44 @@ function extractTextContent(message: Message): string { .join(" "); } +function getLastActivityTime(entries: FileEntry[]): number | undefined { + let lastActivityTime: number | undefined; + + for (const entry of entries) { + if (entry.type !== "message") continue; + + const message = (entry as SessionMessageEntry).message; + if (!isMessageWithContent(message)) continue; + if (message.role !== "user" && message.role !== "assistant") continue; + + const msgTimestamp = (message as { timestamp?: number }).timestamp; + if (typeof msgTimestamp === "number") { + lastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp); + continue; + } + + const entryTimestamp = (entry as SessionEntryBase).timestamp; + if (typeof entryTimestamp === "string") { + const t = new Date(entryTimestamp).getTime(); + if (!Number.isNaN(t)) { + lastActivityTime = Math.max(lastActivityTime ?? 0, t); + } + } + } + + return lastActivityTime; +} + +function getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date { + const lastActivityTime = getLastActivityTime(entries); + if (typeof lastActivityTime === "number" && lastActivityTime > 0) { + return new Date(lastActivityTime); + } + + const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN; + return !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime; +} + async function buildSessionInfo(filePath: string): Promise { try { const content = await readFile(filePath, "utf8"); @@ -550,13 +588,15 @@ async function buildSessionInfo(filePath: string): Promise { const cwd = typeof (header as SessionHeader).cwd === "string" ? (header as SessionHeader).cwd : ""; + const modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime); + return { path: filePath, id: (header as SessionHeader).id, cwd, name, created: new Date((header as SessionHeader).timestamp), - modified: stats.mtime, + modified, messageCount, firstMessage: firstMessage || "(no messages)", allMessagesText: allMessages.join(" "), diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index c6d8d13b..41ee1ba7 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -10,6 +10,7 @@ import { Input, matchesKey, Spacer, + Text, truncateToWidth, visibleWidth, } from "@mariozechner/pi-tui"; @@ -56,6 +57,7 @@ class SessionSelectorHeader implements Component { private confirmingDeletePath: string | null = null; private statusMessage: { type: "info" | "error"; message: string } | null = null; private statusTimeout: ReturnType | null = null; + private showRenameHint = false; constructor(scope: SessionScope, sortMode: SortMode, requestRender: () => void) { this.scope = scope; @@ -85,6 +87,10 @@ class SessionSelectorHeader implements Component { this.showPath = showPath; } + setShowRenameHint(show: boolean): void { + this.showRenameHint = show; + } + setConfirmingDeletePath(path: string | null): void { this.confirmingDeletePath = path; } @@ -146,12 +152,15 @@ class SessionSelectorHeader implements Component { const pathState = this.showPath ? "(on)" : "(off)"; const sep = theme.fg("muted", " · "); const hint1 = keyHint("tab", "scope") + sep + theme.fg("muted", 're: regex · "phrase" exact'); - const hint2 = - rawKeyHint("ctrl+r", "sort") + - sep + - rawKeyHint("ctrl+d", "delete") + - sep + - rawKeyHint("ctrl+p", `path ${pathState}`); + const hint2Parts = [ + rawKeyHint("ctrl+n", "sort"), + rawKeyHint("ctrl+d", "delete"), + rawKeyHint("ctrl+p", `path ${pathState}`), + ]; + if (this.showRenameHint) { + hint2Parts.push(rawKeyHint("ctrl+r", "rename")); + } + const hint2 = hint2Parts.join(sep); hintLine1 = truncateToWidth(hint1, width, "…"); hintLine2 = truncateToWidth(hint2, width, "…"); } @@ -164,6 +173,10 @@ class SessionSelectorHeader implements Component { * Custom session list component with multi-line items and search */ class SessionList implements Component, Focusable { + public getSelectedSessionPath(): string | undefined { + const selected = this.filteredSessions[this.selectedIndex]; + return selected?.path; + } private allSessions: SessionInfo[] = []; private filteredSessions: SessionInfo[] = []; private selectedIndex: number = 0; @@ -181,6 +194,7 @@ class SessionList implements Component, Focusable { public onTogglePath?: (showPath: boolean) => void; public onDeleteConfirmationChange?: (path: string | null) => void; public onDeleteSession?: (sessionPath: string) => Promise; + public onRenameSession?: (sessionPath: string) => void; public onError?: (message: string) => void; private maxVisible: number = 5; // Max sessions visible (each session: message + metadata + optional path + blank) @@ -369,7 +383,7 @@ class SessionList implements Component, Focusable { return; } - if (matchesKey(keyData, "ctrl+r")) { + if (matchesKey(keyData, "ctrl+n")) { this.onToggleSort?.(); return; } @@ -387,6 +401,15 @@ class SessionList implements Component, Focusable { return; } + // Ctrl+R: rename selected session + if (matchesKey(keyData, "ctrl+r")) { + const selected = this.filteredSessions[this.selectedIndex]; + if (selected) { + this.onRenameSession?.(selected.path); + } + return; + } + // Ctrl+Backspace: non-invasive convenience alias for delete // Only triggers deletion when the query is empty; otherwise it is forwarded to the input if (matchesKey(keyData, "ctrl+backspace")) { @@ -483,6 +506,21 @@ async function deleteSessionFile( * Component that renders a session selector */ export class SessionSelectorComponent extends Container implements Focusable { + handleInput(data: string): void { + if (this.mode === "rename") { + const kb = getEditorKeybindings(); + if (kb.matches(data, "selectCancel") || matchesKey(data, "ctrl+c")) { + this.exitRenameMode(); + return; + } + this.renameInput.handleInput(data); + return; + } + + this.sessionList.handleInput(data); + } + + private canRename = true; private sessionList: SessionList; private header: SessionSelectorHeader; private scope: SessionScope = "current"; @@ -493,10 +531,15 @@ export class SessionSelectorComponent extends Container implements Focusable { private allSessionsLoader: SessionsLoader; private onCancel: () => void; private requestRender: () => void; + private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise; private currentLoading = false; private allLoading = false; private allLoadSeq = 0; + private mode: "list" | "rename" = "list"; + private renameInput = new Input(); + private renameTargetPath: string | null = null; + // Focusable implementation - propagate to sessionList for IME cursor positioning private _focused = false; get focused(): boolean { @@ -505,6 +548,24 @@ export class SessionSelectorComponent extends Container implements Focusable { set focused(value: boolean) { this._focused = value; this.sessionList.focused = value; + this.renameInput.focused = value; + if (value && this.mode === "rename") { + this.renameInput.focused = true; + } + } + + private buildBaseLayout(content: Component, options?: { showHeader?: boolean }): void { + this.clear(); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder((s) => theme.fg("accent", s))); + this.addChild(new Spacer(1)); + if (options?.showHeader ?? true) { + this.addChild(this.header); + this.addChild(new Spacer(1)); + } + this.addChild(content); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder((s) => theme.fg("accent", s))); } constructor( @@ -514,6 +575,10 @@ export class SessionSelectorComponent extends Container implements Focusable { onCancel: () => void, onExit: () => void, requestRender: () => void, + options?: { + renameSession?: (sessionPath: string, currentName: string | undefined) => Promise; + showRenameHint?: boolean; + }, currentSessionFilePath?: string, ) { super(); @@ -522,17 +587,20 @@ export class SessionSelectorComponent extends Container implements Focusable { this.onCancel = onCancel; this.requestRender = requestRender; this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.requestRender); - - // Add header - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); - this.addChild(new Spacer(1)); - this.addChild(this.header); - this.addChild(new Spacer(1)); + const renameSession = options?.renameSession; + this.renameSession = renameSession; + this.canRename = !!renameSession; + this.header.setShowRenameHint(options?.showRenameHint ?? this.canRename); // Create session list (starts empty, will be populated after load) this.sessionList = new SessionList([], false, this.sortMode, currentSessionFilePath); + this.buildBaseLayout(this.sessionList); + + this.renameInput.onSubmit = (value) => { + void this.confirmRename(value); + }; + // Ensure header status timeouts are cleared when leaving the selector const clearStatusMessage = () => this.header.setStatusMessage(null); this.sessionList.onSelect = (sessionPath) => { @@ -549,6 +617,15 @@ export class SessionSelectorComponent extends Container implements Focusable { }; this.sessionList.onToggleScope = () => this.toggleScope(); this.sessionList.onToggleSort = () => this.toggleSortMode(); + this.sessionList.onRenameSession = (sessionPath) => { + if (!renameSession) return; + if (this.scope === "current" && this.currentLoading) return; + if (this.scope === "all" && this.allLoading) return; + + const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []); + const session = sessions.find((s) => s.path === sessionPath); + this.enterRenameMode(sessionPath, session?.name); + }; // Sync list events to header this.sessionList.onTogglePath = (showPath) => { @@ -582,6 +659,7 @@ export class SessionSelectorComponent extends Container implements Focusable { const msg = result.method === "trash" ? "Session moved to trash" : "Session deleted"; this.header.setStatusMessage({ type: "info", message: msg }, 2000); + await this.refreshSessionsAfterMutation(); } else { const errorMessage = result.error ?? "Unknown error"; this.header.setStatusMessage({ type: "error", message: `Failed to delete: ${errorMessage}` }, 3000); @@ -590,48 +668,128 @@ export class SessionSelectorComponent extends Container implements Focusable { this.requestRender(); }; - this.addChild(this.sessionList); - - // Add bottom border - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); - // Start loading current sessions immediately this.loadCurrentSessions(); } private loadCurrentSessions(): void { - this.currentLoading = true; - this.header.setScope("current"); + void this.loadScope("current", "initial"); + } + + private enterRenameMode(sessionPath: string, currentName: string | undefined): void { + this.mode = "rename"; + this.renameTargetPath = sessionPath; + this.renameInput.setValue(currentName ?? ""); + this.renameInput.focused = true; + + const panel = new Container(); + panel.addChild(new Text(theme.bold("Rename Session"), 1, 0)); + panel.addChild(new Spacer(1)); + panel.addChild(this.renameInput); + panel.addChild(new Spacer(1)); + panel.addChild(new Text(theme.fg("muted", "Enter to save · Esc/Ctrl+C to cancel"), 1, 0)); + + this.buildBaseLayout(panel, { showHeader: false }); + this.requestRender(); + } + + private exitRenameMode(): void { + this.mode = "list"; + this.renameTargetPath = null; + + this.buildBaseLayout(this.sessionList); + + this.requestRender(); + } + + private async confirmRename(value: string): Promise { + const next = value.trim(); + if (!next) return; + const target = this.renameTargetPath; + if (!target) { + this.exitRenameMode(); + return; + } + + // Find current name for callback + const renameSession = this.renameSession; + if (!renameSession) { + this.exitRenameMode(); + return; + } + + try { + await renameSession(target, next); + await this.refreshSessionsAfterMutation(); + } finally { + this.exitRenameMode(); + } + } + + private async loadScope(scope: SessionScope, reason: "initial" | "refresh" | "toggle"): Promise { + const showCwd = scope === "all"; + + // Mark loading + if (scope === "current") { + this.currentLoading = true; + } else { + this.allLoading = true; + } + + const seq = scope === "all" ? ++this.allLoadSeq : undefined; + this.header.setScope(scope); this.header.setLoading(true); this.requestRender(); - this.currentSessionsLoader((loaded, total) => { - if (this.scope !== "current") return; + const onProgress = (loaded: number, total: number) => { + if (scope !== this.scope) return; + if (seq !== undefined && seq !== this.allLoadSeq) return; this.header.setProgress(loaded, total); this.requestRender(); - }) - .then((sessions) => { + }; + + try { + const sessions = await (scope === "current" + ? this.currentSessionsLoader(onProgress) + : this.allSessionsLoader(onProgress)); + + if (scope === "current") { this.currentSessions = sessions; this.currentLoading = false; + } else { + this.allSessions = sessions; + this.allLoading = false; + } - if (this.scope !== "current") return; + if (scope !== this.scope) return; + if (seq !== undefined && seq !== this.allLoadSeq) return; - this.header.setLoading(false); - this.sessionList.setSessions(sessions, false); - this.requestRender(); - }) - .catch((error: unknown) => { + this.header.setLoading(false); + this.sessionList.setSessions(sessions, showCwd); + this.requestRender(); + + if (scope === "all" && sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) { + this.onCancel(); + } + } catch (err) { + if (scope === "current") { this.currentLoading = false; - const message = error instanceof Error ? error.message : String(error); + } else { + this.allLoading = false; + } - if (this.scope !== "current") return; + if (scope !== this.scope) return; + if (seq !== undefined && seq !== this.allLoadSeq) return; - this.header.setLoading(false); - this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000); - this.sessionList.setSessions([], false); - this.requestRender(); - }); + const message = err instanceof Error ? err.message : String(err); + this.header.setLoading(false); + this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000); + + if (reason === "initial") { + this.sessionList.setSessions([], showCwd); + } + this.requestRender(); + } } private toggleSortMode(): void { @@ -641,6 +799,10 @@ export class SessionSelectorComponent extends Container implements Focusable { this.requestRender(); } + private async refreshSessionsAfterMutation(): Promise { + await this.loadScope(this.scope, "refresh"); + } + private toggleScope(): void { if (this.scope === "current") { this.scope = "all"; @@ -653,55 +815,17 @@ export class SessionSelectorComponent extends Container implements Focusable { return; } - this.header.setLoading(true); - this.sessionList.setSessions([], true); - this.requestRender(); - - if (this.allLoading) return; - - this.allLoading = true; - const seq = ++this.allLoadSeq; - - this.allSessionsLoader((loaded, total) => { - if (seq !== this.allLoadSeq) return; - if (this.scope !== "all") return; - this.header.setProgress(loaded, total); - this.requestRender(); - }) - .then((sessions) => { - this.allSessions = sessions; - this.allLoading = false; - - if (seq !== this.allLoadSeq) return; - if (this.scope !== "all") return; - - this.header.setLoading(false); - this.sessionList.setSessions(sessions, true); - this.requestRender(); - - if (sessions.length === 0 && (this.currentSessions?.length ?? 0) === 0) { - this.onCancel(); - } - }) - .catch((error: unknown) => { - this.allLoading = false; - const message = error instanceof Error ? error.message : String(error); - - if (seq !== this.allLoadSeq) return; - if (this.scope !== "all") return; - - this.header.setLoading(false); - this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000); - this.sessionList.setSessions([], true); - this.requestRender(); - }); - } else { - this.scope = "current"; - this.header.setScope(this.scope); - this.header.setLoading(this.currentLoading); - this.sessionList.setSessions(this.currentSessions ?? [], false); - this.requestRender(); + if (!this.allLoading) { + void this.loadScope("all", "toggle"); + } + return; } + + this.scope = "current"; + this.header.setScope(this.scope); + this.header.setLoading(this.currentLoading); + this.sessionList.setSessions(this.currentSessions ?? [], false); + this.requestRender(); } getSessionList(): SessionList { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index bf256818..dada5c42 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3460,9 +3460,19 @@ export class InteractiveMode { void this.shutdown(); }, () => this.ui.requestRender(), + { + renameSession: async (sessionFilePath: string, nextName: string | undefined) => { + const next = (nextName ?? "").trim(); + if (!next) return; + const mgr = SessionManager.open(sessionFilePath); + mgr.appendSessionInfo(next); + }, + showRenameHint: true, + }, + this.sessionManager.getSessionFile(), ); - return { component: selector, focus: selector.getSessionList() }; + return { component: selector, focus: selector }; }); } diff --git a/packages/coding-agent/test/session-info-modified-timestamp.test.ts b/packages/coding-agent/test/session-info-modified-timestamp.test.ts new file mode 100644 index 00000000..7089cdb2 --- /dev/null +++ b/packages/coding-agent/test/session-info-modified-timestamp.test.ts @@ -0,0 +1,83 @@ +import { writeFileSync } from "node:fs"; +import { stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { SessionHeader } from "../src/core/session-manager.js"; +import { SessionManager } from "../src/core/session-manager.js"; +import { initTheme } from "../src/modes/interactive/theme/theme.js"; + +function createSessionFile(path: string): void { + const header: SessionHeader = { + type: "session", + id: "test-session", + version: 3, + timestamp: new Date(0).toISOString(), + cwd: "/tmp", + }; + writeFileSync(path, `${JSON.stringify(header)}\n`, "utf8"); + + // SessionManager only persists once it has seen at least one assistant message. + // Add a minimal assistant entry so subsequent appends are persisted. + const mgr = SessionManager.open(path); + mgr.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "hi" }], + api: "openai-completions", + provider: "openai", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }); +} + +describe("SessionInfo.modified", () => { + beforeAll(() => initTheme("dark")); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("uses last user/assistant message timestamp instead of file mtime", async () => { + const filePath = join(tmpdir(), `pi-session-${Date.now()}-modified.jsonl`); + createSessionFile(filePath); + + const before = await stat(filePath); + // Ensure the file mtime can differ from our message timestamp even on coarse filesystems. + await new Promise((r) => setTimeout(r, 10)); + + const mgr = SessionManager.open(filePath); + const msgTime = Date.now(); + mgr.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "later" }], + api: "openai-completions", + provider: "openai", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: msgTime, + }); + + const sessions = await SessionManager.list("/tmp", filePath.replace(/\/[^/]+$/, "")); + const s = sessions.find((x) => x.path === filePath); + expect(s).toBeDefined(); + expect(s!.modified.getTime()).toBe(msgTime); + expect(s!.modified.getTime()).not.toBe(before.mtime.getTime()); + }); +}); diff --git a/packages/coding-agent/test/session-selector-rename.test.ts b/packages/coding-agent/test/session-selector-rename.test.ts new file mode 100644 index 00000000..801814d5 --- /dev/null +++ b/packages/coding-agent/test/session-selector-rename.test.ts @@ -0,0 +1,101 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { SessionInfo } from "../src/core/session-manager.js"; +import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.js"; +import { initTheme } from "../src/modes/interactive/theme/theme.js"; + +async function flushPromises(): Promise { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +function makeSession(overrides: Partial & { id: string }): SessionInfo { + return { + path: overrides.path ?? `/tmp/${overrides.id}.jsonl`, + id: overrides.id, + cwd: overrides.cwd ?? "", + name: overrides.name, + created: overrides.created ?? new Date(0), + modified: overrides.modified ?? new Date(0), + messageCount: overrides.messageCount ?? 1, + firstMessage: overrides.firstMessage ?? "hello", + allMessagesText: overrides.allMessagesText ?? "hello", + }; +} + +// Kitty keyboard protocol encoding for Ctrl+R +const CTRL_R = "\x1b[114;5u"; + +describe("session selector rename", () => { + beforeAll(() => { + initTheme("dark"); + }); + + it("shows rename hint in interactive /resume picker configuration", async () => { + const sessions = [makeSession({ id: "a" })]; + const selector = new SessionSelectorComponent( + async () => sessions, + async () => [], + () => {}, + () => {}, + () => {}, + () => {}, + { showRenameHint: true }, + ); + await flushPromises(); + + const output = selector.render(120).join("\n"); + expect(output).toContain("ctrl+r"); + expect(output).toContain("rename"); + }); + + it("does not show rename hint in --resume picker configuration", async () => { + const sessions = [makeSession({ id: "a" })]; + const selector = new SessionSelectorComponent( + async () => sessions, + async () => [], + () => {}, + () => {}, + () => {}, + () => {}, + { showRenameHint: false }, + ); + await flushPromises(); + + const output = selector.render(120).join("\n"); + expect(output).not.toContain("ctrl+r"); + expect(output).not.toContain("rename"); + }); + + it("enters rename mode on Ctrl+R and submits with Enter", async () => { + const sessions = [makeSession({ id: "a", name: "Old" })]; + const renameSession = vi.fn(async () => {}); + + const selector = new SessionSelectorComponent( + async () => sessions, + async () => [], + () => {}, + () => {}, + () => {}, + () => {}, + { renameSession, showRenameHint: true }, + ); + await flushPromises(); + + selector.getSessionList().handleInput(CTRL_R); + await flushPromises(); + + // Rename mode layout + const output = selector.render(120).join("\n"); + expect(output).toContain("Rename Session"); + expect(output).not.toContain("Resume Session"); + + // Type and submit + selector.handleInput("X"); + selector.handleInput("\r"); + await flushPromises(); + + expect(renameSession).toHaveBeenCalledTimes(1); + expect(renameSession).toHaveBeenCalledWith(sessions[0]!.path, "XOld"); + }); +});