Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions App/memmy-agent/src/entrypoints/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 25 additions & 3 deletions App/memmy-agent/src/entrypoints/cli/tui-gateway-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export type TuiGatewayState = {
activeTurnId: string | null;
startedAt: number | null;
messages: TuiGatewayMessage[];
sessionResetVersion: number;
goalState: Record<string, unknown> | null;
modelName: string | null;
modelSelection: TuiModelSelection | null;
Expand All @@ -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";
Expand Down Expand Up @@ -429,6 +434,7 @@ export class TuiGatewayClient {
private readonly listeners = new Set<(state: TuiGatewayState) => void>();
private readonly pendingSubmissions = new Map<string, PendingSubmission>();
private readonly acceptedModelUpdateRequests = new Set<string>();
private readonly sessionResetRequestIds = new Set<string>();
private readonly queuedContents = new Map<string, string>();
private readonly historyBuffers = new Map<number, GatewayEvent[]>();
private socket: TuiWebSocket | null = null;
Expand All @@ -453,6 +459,7 @@ export class TuiGatewayClient {
activeTurnId: null,
startedAt: null,
messages: [],
sessionResetVersion: 0,
goalState: null,
modelName: null,
modelSelection: null,
Expand Down Expand Up @@ -513,6 +520,7 @@ export class TuiGatewayClient {
}
this.pendingSubmissions.clear();
this.acceptedModelUpdateRequests.clear();
this.sessionResetRequestIds.clear();
this.patch({
connection: "closed",
attached: false,
Expand Down Expand Up @@ -561,6 +569,7 @@ export class TuiGatewayClient {
sentGeneration: null,
waiters: new Set<SubmissionWaiter>(),
};
if (isNewSessionCommand(text)) this.sessionResetRequestIds.add(clientRequestId);
this.pendingSubmissions.set(clientRequestId, attempt);
const result = this.waitForSubmission(attempt);
this.sendSubmission(attempt);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 15 additions & 1 deletion App/memmy-agent/src/entrypoints/cli/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 = [
"███╗ ███╗ ███████╗ ███╗ ███╗ ███╗ ███╗ ██╗ ██╗",
"████╗ ████║ ██╔════╝ ████╗ ████║ ████╗ ████║ ╚██╗ ██╔╝",
Expand Down Expand Up @@ -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("");
Expand All @@ -978,6 +984,7 @@ function MemmyTui({ config, gateway, registerCleanup, target, toolsets, version
>(null);
const [localMessages, setLocalMessages] = useState<TuiMessage[]>(() => []);
const [gatewayState, setGatewayState] = useState<TuiGatewayState>(() => gateway.snapshot());
const handledSessionResetVersionRef = useRef(gatewayState.sessionResetVersion);
const gatewayStateRef = useRef(gatewayState);
const [notice, setNotice] = useState("");
const [now, setNow] = useState(() => Date.now());
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -1378,7 +1392,7 @@ export async function runInkInteractiveAgent(
cleanup = next;
};

process.stdout.write("\x1b[2J\x1b[H\x1b[3J");
clearTerminalScreen();
const instance = render(
<MemmyTui
config={config}
Expand Down
2 changes: 1 addition & 1 deletion App/memmy-agent/src/memmy-memory/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class MemmyMemoryHttpError extends Error {

type FetchLike = typeof fetch;

export const DEFAULT_MEMOS_MEMORY_TIMEOUT_MS = 20_000;
export const DEFAULT_MEMOS_MEMORY_TIMEOUT_MS = 60_000;

export class MemmyMemoryClient {
baseUrl: string;
Expand Down
10 changes: 6 additions & 4 deletions App/memmy-agent/tests/entrypoints/cli/terminal-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,17 @@ afterEach(() => {
});

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,
Expand All @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>((resolve) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion App/memmy-agent/tests/memmy-memory/client-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading