diff --git a/App/memmy-agent/src/entrypoints/cli/commands.ts b/App/memmy-agent/src/entrypoints/cli/commands.ts index d5ec7d96..b280d533 100644 --- a/App/memmy-agent/src/entrypoints/cli/commands.ts +++ b/App/memmy-agent/src/entrypoints/cli/commands.ts @@ -327,11 +327,13 @@ export function resolveTerminalTarget( sessionId = null, standalone = false, project = null, + fresh = false, invocationCwd = process.cwd(), }: { sessionId?: string | null; standalone?: boolean; project?: string | null; + fresh?: boolean; invocationCwd?: string; } = {}, ): TerminalTarget { @@ -343,7 +345,7 @@ export function resolveTerminalTarget( || typeof sessions?.save !== "function" ) { const fallbackId = sessionId - ?? (standalone || project ? `cli:${crypto.randomUUID()}` : "cli:direct"); + ?? (standalone || project || fresh ? `cli:${crypto.randomUUID()}` : "cli:direct"); return { sessionId: fallbackId, target: project ? "project" : "standalone", @@ -381,7 +383,7 @@ export function resolveTerminalTarget( projectName = registered.name; } } else { - key = standalone || project ? `cli:${crypto.randomUUID()}` : "cli:direct"; + key = standalone || project || fresh ? `cli:${crypto.randomUUID()}` : "cli:direct"; const existing = reload(key); if (!existing && !dependencies.hasUsableDefaultModel()) { throw new Error("No usable default model is configured. Run `memmy onboard` first."); @@ -508,7 +510,7 @@ export async function runRootInteractiveAgent({ return false; } }, - }, { sessionId, standalone, project }); + }, { sessionId, standalone, project, fresh: true }); printCliRestartNoticeIfNeeded(target.sessionId, true); const { runInkInteractiveAgent } = await import("./tui.js"); return runInkInteractiveAgent(loaded, target.sessionId, target); diff --git a/App/memmy-agent/src/entrypoints/cli/tui-gateway-client.ts b/App/memmy-agent/src/entrypoints/cli/tui-gateway-client.ts index 7b13a834..83723c2f 100644 --- a/App/memmy-agent/src/entrypoints/cli/tui-gateway-client.ts +++ b/App/memmy-agent/src/entrypoints/cli/tui-gateway-client.ts @@ -76,6 +76,7 @@ export type TuiGatewayState = { activeTurnId: string | null; startedAt: number | null; messages: TuiGatewayMessage[]; + sessionResetVersion: number; goalState: Record | null; modelName: string | null; modelSelection: TuiModelSelection | null; @@ -86,6 +87,10 @@ export type TuiGatewayState = { notice: string; }; +function isNewSessionCommand(content: string): boolean { + return content.trim().toLowerCase() === "/new"; +} + export type TuiGatewaySubmissionResult = { clientRequestId: string; status: "accepted" | "queued" | "steered"; @@ -429,6 +434,7 @@ export class TuiGatewayClient { private readonly listeners = new Set<(state: TuiGatewayState) => void>(); private readonly pendingSubmissions = new Map(); private readonly acceptedModelUpdateRequests = new Set(); + private readonly sessionResetRequestIds = new Set(); private readonly queuedContents = new Map(); private readonly historyBuffers = new Map(); private socket: TuiWebSocket | null = null; @@ -453,6 +459,7 @@ export class TuiGatewayClient { activeTurnId: null, startedAt: null, messages: [], + sessionResetVersion: 0, goalState: null, modelName: null, modelSelection: null, @@ -513,6 +520,7 @@ export class TuiGatewayClient { } this.pendingSubmissions.clear(); this.acceptedModelUpdateRequests.clear(); + this.sessionResetRequestIds.clear(); this.patch({ connection: "closed", attached: false, @@ -561,6 +569,7 @@ export class TuiGatewayClient { sentGeneration: null, waiters: new Set(), }; + if (isNewSessionCommand(text)) this.sessionResetRequestIds.add(clientRequestId); this.pendingSubmissions.set(clientRequestId, attempt); const result = this.waitForSubmission(attempt); this.sendSubmission(attempt); @@ -779,12 +788,16 @@ export class TuiGatewayClient { if (event.event === "message_queue_removed") { const id = stringValue(event.client_request_id); this.applyQueueIncrement(event, (items) => items.filter((candidate) => candidate.clientRequestId !== id)); - if (id) this.queuedContents.delete(id); + if (id) { + this.queuedContents.delete(id); + this.sessionResetRequestIds.delete(id); + } return; } if (event.event === "message_steered") { const id = stringValue(event.client_request_id); if (id) this.promoteSubmission(id, stringValue(event.turn_id)); + if (id) this.sessionResetRequestIds.delete(id); this.resolveSubmission(id, "steered"); return; } @@ -796,7 +809,13 @@ export class TuiGatewayClient { if (id && attempt?.content.trim().match(/^\/model\s+\S+$/i)) { this.acceptedModelUpdateRequests.add(id); } - if (id && !this.state.queueItems.some((item) => item.clientRequestId === id)) { + const resetSession = id ? this.sessionResetRequestIds.delete(id) : false; + if (resetSession) { + this.patch({ + messages: [], + sessionResetVersion: this.state.sessionResetVersion + 1, + }); + } else if (id && !this.state.queueItems.some((item) => item.clientRequestId === id)) { this.promoteSubmission(id, stringValue(event.turn_id)); } this.resolveSubmission(id, "accepted"); @@ -843,7 +862,10 @@ export class TuiGatewayClient { } if (event.event === "error") { const id = stringValue(event.client_request_id); - if (id) this.rejectSubmission(id, new Error(stringValue(event.reason) ?? stringValue(event.detail) ?? "Gateway rejected message")); + if (id) { + this.sessionResetRequestIds.delete(id); + this.rejectSubmission(id, new Error(stringValue(event.reason) ?? stringValue(event.detail) ?? "Gateway rejected message")); + } if (this.stopRequest && event.detail === "stop_failed") { const pending = this.stopRequest; this.stopRequest = null; diff --git a/App/memmy-agent/src/entrypoints/cli/tui.tsx b/App/memmy-agent/src/entrypoints/cli/tui.tsx index d46d2052..f946c90f 100644 --- a/App/memmy-agent/src/entrypoints/cli/tui.tsx +++ b/App/memmy-agent/src/entrypoints/cli/tui.tsx @@ -71,6 +71,7 @@ type YogaLayoutNode = { const PROMPT = "❯"; const MAX_MESSAGES = 24; const MAX_TOOLSET_ROWS = 5; +const CLEAR_TERMINAL_SEQUENCE = "\x1b[2J\x1b[H\x1b[3J"; const THINK_FRAMES = ["planning", "working", "calling tools", "reading", "writing"]; const PALETTE = { accent: "#F59E6B", @@ -86,6 +87,10 @@ const PALETTE = { success: "#2DC999", }; +export function clearTerminalScreen(write: (data: string) => unknown = (data) => process.stdout.write(data)): void { + write(CLEAR_TERMINAL_SEQUENCE); +} + const WORDMARK_ROWS = [ "███╗ ███╗ ███████╗ ███╗ ███╗ ███╗ ███╗ ██╗ ██╗", "████╗ ████║ ██╔════╝ ████╗ ████║ ████╗ ████║ ╚██╗ ██╔╝", @@ -962,6 +967,7 @@ function QueuePanel({ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version }: TuiProps) { const { exit } = useApp(); + const { write } = useStdout(); const { columns, rows } = useTerminalSize(); const idRef = useRef(1); const [input, setInput] = useState(""); @@ -978,6 +984,7 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version >(null); const [localMessages, setLocalMessages] = useState(() => []); const [gatewayState, setGatewayState] = useState(() => gateway.snapshot()); + const handledSessionResetVersionRef = useRef(gatewayState.sessionResetVersion); const gatewayStateRef = useRef(gatewayState); const [notice, setNotice] = useState(""); const [now, setNow] = useState(() => Date.now()); @@ -1025,6 +1032,13 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version }; }, [gateway, registerCleanup]); + useEffect(() => { + if (handledSessionResetVersionRef.current === gatewayState.sessionResetVersion) return; + handledSessionResetVersionRef.current = gatewayState.sessionResetVersion; + setLocalMessages([]); + clearTerminalScreen(write); + }, [gatewayState.sessionResetVersion, write]); + useEffect(() => { if (!gatewayState.busy) return; setNow(Date.now()); @@ -1378,7 +1392,7 @@ export async function runInkInteractiveAgent( cleanup = next; }; - process.stdout.write("\x1b[2J\x1b[H\x1b[3J"); + clearTerminalScreen(); const instance = render( { }); describe("terminal target resolution", () => { - it("creates cli:direct as a fixed standalone session by default", () => { + it("creates a fresh standalone session when requested by the root TUI", () => { const { dependencies, loop, workspace } = makeLoop(); - const target = resolveTerminalTarget(dependencies); + const target = resolveTerminalTarget(dependencies, { fresh: true }); expect(target).toMatchObject({ - sessionId: "cli:direct", target: "standalone", projectId: null, cwd: workspace, }); - expect(loop.sessions.loadSession("cli:direct")?.metadata).toMatchObject({ + expect(target.sessionId).toMatch(/^cli:[0-9a-f-]{36}$/); + expect(resolveTerminalTarget(dependencies, { fresh: true }).sessionId).not.toBe(target.sessionId); + expect(loop.sessions.loadSession(target.sessionId)?.metadata).toMatchObject({ webui: true, webuiProjectId: null, webuiWorkspaceCwd: workspace, @@ -94,6 +95,7 @@ describe("terminal target resolution", () => { const created = resolveTerminalTarget(dependencies, { standalone: true }); expect(created.sessionId).toMatch(/^cli:[0-9a-f-]{36}$/); expect(resolveTerminalTarget(dependencies, { sessionId: created.sessionId })).toEqual(created); + expect(resolveTerminalTarget(dependencies, { sessionId: created.sessionId, fresh: true })).toEqual(created); expect(() => resolveTerminalTarget(dependencies, { sessionId: "telegram:123" })) .toThrow("--session only accepts"); expect(() => resolveTerminalTarget(dependencies, { diff --git a/App/memmy-agent/tests/entrypoints/cli/tui-gateway-client.test.ts b/App/memmy-agent/tests/entrypoints/cli/tui-gateway-client.test.ts index b0b481e2..3e62d149 100644 --- a/App/memmy-agent/tests/entrypoints/cli/tui-gateway-client.test.ts +++ b/App/memmy-agent/tests/entrypoints/cli/tui-gateway-client.test.ts @@ -221,6 +221,60 @@ describe("TuiGatewayClient", () => { client.close(); }); + it("clears hydrated transcript when an accepted TUI /new resets the Session", async () => { + const { client, sockets } = await connectClient({ + historyMessages: [ + { id: "user-old", role: "user", content: "old question" }, + { id: "assistant-old", role: "assistant", content: "old answer" }, + ], + }); + const socket = sockets[0]!; + const requestId = "11111111-1111-4111-8111-111111111111"; + const submission = client.submit("/new", "queue", requestId); + const queuedItem = { + client_request_id: requestId, + text: "/new", + queued_at: "2026-08-09T12:00:00.000Z", + source: { kind: "tui", channel: "websocket" }, + }; + + socket.message({ + event: "message_queued", + chat_id: client.chatId, + client_request_id: requestId, + revision: 1, + item: queuedItem, + }); + await expect(submission).resolves.toMatchObject({ status: "queued" }); + expect(client.snapshot().messages.map((message) => message.text)).toEqual(["old question", "old answer"]); + + socket.message({ + event: "message_dequeued", + chat_id: client.chatId, + client_request_id: requestId, + revision: 2, + item: queuedItem, + }); + socket.message({ + event: "message_accepted", + chat_id: client.chatId, + client_request_id: requestId, + turn_id: "turn-new", + }); + + expect(client.snapshot()).toMatchObject({ messages: [], sessionResetVersion: 1 }); + + socket.message({ + event: "message", + chat_id: client.chatId, + text: "New session started.", + turn_id: "turn-new", + source: { kind: "tui", channel: "websocket" }, + }); + expect(client.snapshot().messages.map((message) => message.text)).toEqual(["New session started."]); + client.close(); + }); + it("buffers current-generation live events during history and hides non-TUI transcript events", async () => { let resolveHistory!: (value: Response) => void; const history = new Promise((resolve) => { diff --git a/App/memmy-agent/tests/entrypoints/cli/tui-turn-admission.test.tsx b/App/memmy-agent/tests/entrypoints/cli/tui-turn-admission.test.tsx index 344eecfa..90377970 100644 --- a/App/memmy-agent/tests/entrypoints/cli/tui-turn-admission.test.tsx +++ b/App/memmy-agent/tests/entrypoints/cli/tui-turn-admission.test.tsx @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + clearTerminalScreen, tuiQueuePreview, tuiQueueSourceLabel, } from "../../../src/entrypoints/cli/tui.js"; @@ -69,6 +70,16 @@ describe("Ink TUI Turn admission", () => { expect(clearIndex).toBeGreaterThan(sendIndex); }); + it("clears local messages and terminal scrollback after the Session resets", () => { + const write = vi.fn(); + clearTerminalScreen(write); + + expect(write).toHaveBeenCalledWith("\x1b[2J\x1b[H\x1b[3J"); + expect(source).toContain("handledSessionResetVersionRef.current === gatewayState.sessionResetVersion"); + expect(source).toContain("setLocalMessages([]);"); + expect(source).toContain("clearTerminalScreen(write);"); + }); + it("renders fixed queue sources and normalizes only the preview", () => { expect(tuiQueueSourceLabel({ clientRequestId: "id", diff --git a/App/memmy-agent/tests/memmy-memory/client-tools.test.ts b/App/memmy-agent/tests/memmy-memory/client-tools.test.ts index 7e61fcb8..53a39ea7 100644 --- a/App/memmy-agent/tests/memmy-memory/client-tools.test.ts +++ b/App/memmy-agent/tests/memmy-memory/client-tools.test.ts @@ -49,9 +49,10 @@ function validHealth( } describe("MemmyMemoryClient", () => { - it("uses a 20s default request timeout", () => { + it("uses a 60s default request timeout", () => { const client = new MemmyMemoryClient({ baseUrl: "http://memory.test" }); + expect(DEFAULT_MEMOS_MEMORY_TIMEOUT_MS).toBe(60_000); expect(client.timeoutMs).toBe(DEFAULT_MEMOS_MEMORY_TIMEOUT_MS); });