Skip to content

Commit 3328adb

Browse files
fitz123claude
andauthored
feat: /clean for full context wipe, rename /reset to /reconnect (#82)
* add plan: reset-restart-commands * add plan: reset-restart-commands * feat: add destroySession() method to SessionManager Adds a new public method that closes the session AND deletes stored state from the session store, so the next message starts a completely fresh session with no --resume. This is the foundation for the /clean command. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: rename /reset to /reconnect and add /clean command for Telegram /reconnect keeps session file (prior context partially retained). /clean calls destroySession() to wipe stored state for a fresh start. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: rename /reset to /reconnect and add /clean command for Discord Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update error messages to reference /reconnect instead of /reset Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add tests for /reconnect and /clean session lifecycle Tests verify closeSession preserves stored state (reconnect can resume) while destroySession deletes it (clean starts fresh). BOT_COMMANDS assertion and existing destroySession tests already covered by prior commits. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update docs to reference /reconnect and /clean instead of /reset Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review findings Add handler-level wiring tests verifying /reconnect calls closeSession and /clean calls destroySession (plan items 5.2, 5.3). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review findings - Revert unrelated DM typing suppression in telegram-adapter.ts (out of scope) - Fix TypeScript errors in test mocks (missing UserFromGetMe and PrivateChat properties) - Clarify test assertion comment for /clean handler wiring Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: fitz123 <fitz123@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aaa6a76 commit 3328adb

9 files changed

Lines changed: 346 additions & 24 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Plan: /clean for full context wipe, rename /reset to /reconnect
2+
3+
GitHub issue: #81
4+
5+
## Problem
6+
7+
Current `/reset` kills subprocess but keeps session file. Resume brings back compacted history. No way to get a clean slate.
8+
9+
## Changes
10+
11+
### 1. session-manager.ts — add `destroySession()` method
12+
13+
Add a new public method that closes the session AND deletes stored state:
14+
15+
```ts
16+
async destroySession(chatId: string): Promise<void> {
17+
await this.closeSession(chatId);
18+
this.store.deleteSession(chatId);
19+
}
20+
```
21+
22+
`closeSession()` already handles: kill subprocess, clear idle timer, clean outbox/inject dirs, remove from active map. `destroySession()` adds: delete from store so no `--resume` happens.
23+
24+
- [x] Add `destroySession()` method to SessionManager
25+
26+
### 2. telegram-bot.ts — rename `/reset` to `/reconnect`, add `/clean`
27+
28+
Command list (line ~29):
29+
```ts
30+
{ command: "reconnect", description: "Reconnect session (keeps context)" },
31+
{ command: "clean", description: "Clean session (fresh start)" },
32+
```
33+
34+
Remove the old "reset" entry from command list.
35+
36+
Rename existing handler at line 588 from `bot.command("reset", ...)` to `bot.command("reconnect", ...)`.
37+
Keep reply: `"Session restarted. Prior context may be partially retained."`
38+
39+
Add new `/clean` handler:
40+
```ts
41+
bot.command("clean", async (ctx) => {
42+
const topicId = ctx.message?.message_thread_id;
43+
if (ctx.message) setThread(ctx.chat.id, ctx.message.message_id, topicId);
44+
const binding = resolveBinding(ctx.chat.id, config.bindings, topicId);
45+
if (!binding) return;
46+
if (ctx.message && isStaleMessage(ctx.message.date * 1000, maxMessageAgeMs)) {
47+
log.debug("telegram-bot", `Discarding stale /clean for chat ${ctx.chat.id}`);
48+
return;
49+
}
50+
const key = sessionKey(ctx.chat.id, topicId);
51+
messageQueue.clear(key);
52+
await sessionManager.destroySession(key);
53+
await ctx.reply("Session cleaned. Fresh start.");
54+
});
55+
```
56+
57+
- [x] Update command list: replace "reset" with "reconnect" and "clean"
58+
- [x] Rename existing reset handler to "reconnect"
59+
- [x] Add new "/clean" handler calling `destroySession()`
60+
61+
### 3. discord-bot.ts — same changes
62+
63+
Rename slash command from "reset" to "reconnect", add new "clean" slash command.
64+
Update handlers accordingly.
65+
66+
- [x] Update Discord slash commands
67+
- [x] Update Discord handlers
68+
69+
### 4. Error messages — update references
70+
71+
In `message-queue.ts` and `session-manager.ts`, error messages say "use /reset". Change to "/reconnect" since users hitting errors want to retry with context, not nuke everything.
72+
73+
Files:
74+
- `bot/src/message-queue.ts` lines 206, 282 — change `/reset` to `/reconnect`
75+
- `bot/src/session-manager.ts` line 180 — change `/reset` to `/reconnect`
76+
77+
- [x] Update error messages to reference /reconnect
78+
79+
### 5. Tests
80+
81+
- [x] Update telegram-bot.test.ts command list assertion to include "reconnect" and "clean" instead of "reset"
82+
- [x] Add test: `/reconnect` calls `closeSession()` (not `destroySession()`)
83+
- [x] Add test: `/clean` calls `destroySession()`
84+
- [x] Add test: `destroySession()` calls `closeSession()` then `deleteSession()` on store
85+
- [x] Update any existing reset tests to use "reconnect"
86+
- [x] Update Discord tests if they exist
87+
88+
## Checklist
89+
90+
- [x] `destroySession()` in session-manager.ts
91+
- [x] Telegram: rename to /reconnect, add /clean
92+
- [x] Discord: rename to /reconnect, add /clean
93+
- [x] Error messages → /reconnect
94+
- [x] Tests pass

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ Initial public release of Minime — a multi-platform bot that routes messages t
4949
- Reaction event logging to JSONL
5050
- In-memory message-thread cache with disk persistence for reaction routing
5151
- Message content index for reaction context lookups
52-
- Bot commands: `/start`, `/reset`, `/status`
52+
- Bot commands: `/start`, `/reconnect`, `/clean`, `/status`
5353

5454
### Discord Features
5555

5656
- Guild-wide bindings with per-channel overrides
5757
- Thread support with automatic parent channel binding inheritance
58-
- Slash commands (`/start`, `/reset`, `/status`) registered per-guild on startup
58+
- Slash commands (`/start`, `/reconnect`, `/clean`, `/status`) registered per-guild on startup
5959
- File attachment handling and voice transcription
6060
- Mention detection with configurable `requireMention`
6161

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ To remove: `launchctl bootout gui/$(id -u)/ai.minime.cron.<name>`, delete from `
273273

274274
See [config.yaml](config.yaml) for per-channel overrides and guild-wide defaults.
275275

276-
3. Required bot permissions/intents: Guilds, GuildMessages, MessageContent (privileged), DirectMessages. Slash commands (`/start`, `/reset`, `/status`) are registered per-guild on startup.
276+
3. Required bot permissions/intents: Guilds, GuildMessages, MessageContent (privileged), DirectMessages. Slash commands (`/start`, `/reconnect`, `/clean`, `/status`) are registered per-guild on startup.
277277

278278
`telegramTokenService` is optional — the bot can run Discord-only.
279279

bot/src/__tests__/session-manager.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,106 @@ describe("SessionManager", () => {
126126
await manager.closeSession("nonexistent");
127127
});
128128

129+
it("destroySession is safe for unknown chatId", async () => {
130+
const { SessionManager } = await import("../session-manager.js");
131+
const manager = new SessionManager(testConfig, TEST_STORE_PATH);
132+
// Should not throw
133+
await manager.destroySession("nonexistent");
134+
});
135+
136+
it("closeSession preserves stored state (reconnect can resume)", async () => {
137+
const { SessionManager } = await import("../session-manager.js");
138+
const { SessionStore } = await import("../session-store.js");
139+
140+
// Pre-populate store with a session
141+
const store = new SessionStore(TEST_STORE_PATH);
142+
store.setSession("chat-reconnect", {
143+
sessionId: "reconnect-session-id",
144+
chatId: "chat-reconnect",
145+
agentId: "main",
146+
lastActivity: Date.now(),
147+
});
148+
149+
const manager = new SessionManager(testConfig, TEST_STORE_PATH);
150+
await manager.closeSession("chat-reconnect");
151+
152+
// Stored state should still exist — /reconnect preserves it for resume
153+
const storeAfter = new SessionStore(TEST_STORE_PATH);
154+
assert.ok(storeAfter.getSession("chat-reconnect"), "closeSession should preserve stored state");
155+
156+
// resolveStoredSession should find the stored session and allow resume
157+
const result = manager.resolveStoredSession("chat-reconnect", "main");
158+
assert.strictEqual(result.resume, true, "closed session should resume on next message");
159+
});
160+
161+
it("destroySession closes session and deletes stored state", async () => {
162+
const { SessionManager } = await import("../session-manager.js");
163+
const { SessionStore } = await import("../session-store.js");
164+
165+
// Pre-populate store with a session
166+
const store = new SessionStore(TEST_STORE_PATH);
167+
store.setSession("chat-destroy", {
168+
sessionId: "destroy-session-id",
169+
chatId: "chat-destroy",
170+
agentId: "main",
171+
lastActivity: Date.now(),
172+
});
173+
// Also store another session that should NOT be affected
174+
store.setSession("chat-keep", {
175+
sessionId: "keep-session-id",
176+
chatId: "chat-keep",
177+
agentId: "main",
178+
lastActivity: Date.now(),
179+
});
180+
181+
const manager = new SessionManager(testConfig, TEST_STORE_PATH);
182+
183+
await manager.destroySession("chat-destroy");
184+
185+
// Verify stored state was deleted
186+
const storeAfter = new SessionStore(TEST_STORE_PATH);
187+
assert.strictEqual(storeAfter.getSession("chat-destroy"), undefined, "destroyed session should be removed from store");
188+
assert.ok(storeAfter.getSession("chat-keep"), "other sessions should be unaffected");
189+
190+
// Verify resolveStoredSession returns fresh (no resume)
191+
const result = manager.resolveStoredSession("chat-destroy", "main");
192+
assert.strictEqual(result.resume, false, "destroyed session should not resume");
193+
});
194+
195+
it("destroySession deletes state that closeSession would preserve", async () => {
196+
const { SessionManager } = await import("../session-manager.js");
197+
const { SessionStore } = await import("../session-store.js");
198+
199+
// Pre-populate store with two sessions
200+
const store = new SessionStore(TEST_STORE_PATH);
201+
const now = Date.now();
202+
store.setSession("chat-close", {
203+
sessionId: "close-sid",
204+
chatId: "chat-close",
205+
agentId: "main",
206+
lastActivity: now,
207+
});
208+
store.setSession("chat-destroy", {
209+
sessionId: "destroy-sid",
210+
chatId: "chat-destroy",
211+
agentId: "main",
212+
lastActivity: now,
213+
});
214+
215+
const manager = new SessionManager(testConfig, TEST_STORE_PATH);
216+
217+
// closeSession (what /reconnect calls) — preserves store
218+
await manager.closeSession("chat-close");
219+
// destroySession (what /clean calls) — deletes from store
220+
await manager.destroySession("chat-destroy");
221+
222+
const closeResult = manager.resolveStoredSession("chat-close", "main");
223+
const destroyResult = manager.resolveStoredSession("chat-destroy", "main");
224+
225+
assert.strictEqual(closeResult.resume, true, "/reconnect: session resumes");
226+
assert.strictEqual(destroyResult.resume, false, "/clean: session starts fresh");
227+
});
228+
129229
it("throws for unknown agent", async () => {
130230
const { SessionManager } = await import("../session-manager.js");
131231
const manager = new SessionManager(testConfig, TEST_STORE_PATH);

bot/src/__tests__/telegram-bot.test.ts

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
process.env.TZ = "UTC";
22
import { describe, it } from "node:test";
33
import assert from "node:assert/strict";
4-
import { resolveBinding, isAuthorized, sessionKey, isImageMimeType, imageExtensionForMime, buildSourcePrefix, shouldRespondInGroup, BOT_COMMANDS, isStaleMessage, buildReplyContext, buildForwardContext, extensionForDocument, formatFileSize, formatDocumentMeta, buildReactionContext, AUTO_RETRY_OPTIONS, extractMediaInfo, extensionForMedia, formatMediaMeta } from "../telegram-bot.js";
5-
import type { TelegramBinding } from "../types.js";
4+
import { resolveBinding, isAuthorized, sessionKey, isImageMimeType, imageExtensionForMime, buildSourcePrefix, shouldRespondInGroup, BOT_COMMANDS, isStaleMessage, buildReplyContext, buildForwardContext, extensionForDocument, formatFileSize, formatDocumentMeta, buildReactionContext, AUTO_RETRY_OPTIONS, extractMediaInfo, extensionForMedia, formatMediaMeta, createTelegramBot } from "../telegram-bot.js";
5+
import type { TelegramBinding, BotConfig } from "../types.js";
6+
import type { SessionManager } from "../session-manager.js";
67

78
const testBindings: TelegramBinding[] = [
89
{ chatId: 111111111, agentId: "main", kind: "dm", label: "User1 DM" },
@@ -191,9 +192,9 @@ describe("isImageMimeType", () => {
191192
});
192193

193194
describe("BOT_COMMANDS", () => {
194-
it("contains start, reset, and status commands", () => {
195+
it("contains start, reconnect, clean, and status commands", () => {
195196
const names = BOT_COMMANDS.map((c) => c.command);
196-
assert.deepStrictEqual(names, ["start", "reset", "status"]);
197+
assert.deepStrictEqual(names, ["start", "reconnect", "clean", "status"]);
197198
});
198199

199200
it("each command has a non-empty description", () => {
@@ -1257,3 +1258,93 @@ describe("formatMediaMeta", () => {
12571258
);
12581259
});
12591260
});
1261+
1262+
describe("command handler wiring", () => {
1263+
const testChatId = 111111111;
1264+
1265+
const handlerConfig: BotConfig = {
1266+
telegramToken: "test:fake-token-for-handler-tests",
1267+
agents: {
1268+
main: { id: "main", workspaceCwd: "/tmp/test", model: "claude-opus-4-6" },
1269+
},
1270+
bindings: [
1271+
{ chatId: testChatId, agentId: "main", kind: "dm" as const },
1272+
],
1273+
sessionDefaults: {
1274+
idleTimeoutMs: 60000,
1275+
maxConcurrentSessions: 2,
1276+
maxMessageAgeMs: 300000,
1277+
requireMention: false,
1278+
},
1279+
};
1280+
1281+
function createMockSessionManager(): SessionManager & { calls: string[] } {
1282+
const calls: string[] = [];
1283+
return {
1284+
calls,
1285+
closeSession: async (_chatId: string) => { calls.push("closeSession"); },
1286+
destroySession: async (_chatId: string) => { calls.push("destroySession"); },
1287+
sendSessionMessage: () => { throw new Error("unexpected"); },
1288+
getOrCreateSession: async () => { throw new Error("unexpected"); },
1289+
closeAll: async () => {},
1290+
resolveStoredSession: () => ({ resume: false }),
1291+
activeCount: () => 0,
1292+
getActiveSession: () => undefined,
1293+
isActive: () => false,
1294+
} as unknown as SessionManager & { calls: string[] };
1295+
}
1296+
1297+
function makeCommandUpdate(command: string, updateId: number) {
1298+
const text = `/${command}`;
1299+
return {
1300+
update_id: updateId,
1301+
message: {
1302+
message_id: updateId,
1303+
from: { id: testChatId, is_bot: false, first_name: "Test" },
1304+
chat: { id: testChatId, type: "private" as const, first_name: "Test" },
1305+
date: Math.floor(Date.now() / 1000),
1306+
text,
1307+
entities: [{ offset: 0, length: text.length, type: "bot_command" as const }],
1308+
},
1309+
};
1310+
}
1311+
1312+
function initBot(mockSM: SessionManager) {
1313+
const { bot } = createTelegramBot(handlerConfig, mockSM);
1314+
// Intercept all API calls so nothing reaches Telegram
1315+
bot.api.config.use(async (_prev, _method, _payload) => ({ ok: true, result: true } as any));
1316+
// Provide bot info so handleUpdate works without calling getMe
1317+
bot.botInfo = {
1318+
id: 999,
1319+
is_bot: true,
1320+
first_name: "TestBot",
1321+
username: "test_bot",
1322+
can_join_groups: false,
1323+
can_read_all_group_messages: false,
1324+
supports_inline_queries: false,
1325+
can_connect_to_business: false,
1326+
has_main_web_app: false,
1327+
has_topics_enabled: false,
1328+
allows_users_to_create_topics: false,
1329+
};
1330+
return bot;
1331+
}
1332+
1333+
it("/reconnect calls closeSession (not destroySession)", async () => {
1334+
const mockSM = createMockSessionManager();
1335+
const bot = initBot(mockSM);
1336+
1337+
await bot.handleUpdate(makeCommandUpdate("reconnect", 1));
1338+
assert.ok(mockSM.calls.includes("closeSession"), "/reconnect should call closeSession");
1339+
assert.ok(!mockSM.calls.includes("destroySession"), "/reconnect should NOT call destroySession");
1340+
});
1341+
1342+
it("/clean calls destroySession (not closeSession directly)", async () => {
1343+
const mockSM = createMockSessionManager();
1344+
const bot = initBot(mockSM);
1345+
1346+
await bot.handleUpdate(makeCommandUpdate("clean", 2));
1347+
assert.ok(mockSM.calls.includes("destroySession"), "/clean should call destroySession");
1348+
assert.ok(!mockSM.calls.includes("closeSession"), "/clean handler calls destroySession, not closeSession directly");
1349+
});
1350+
});

bot/src/discord-bot.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -339,17 +339,25 @@ export async function createDiscordBot(
339339
);
340340
break;
341341
}
342-
// Session lifecycle: create → compact → reset → resume. The reset
342+
// Session lifecycle: create → compact → reconnect → resume. Reconnect
343343
// kills the Claude subprocess but the session file (with compacted
344344
// conversation history) remains on disk. When the next message arrives,
345345
// getOrCreateSession() finds the file and resumes with --resume, so
346346
// prior context may be partially retained through the compaction summary.
347-
case "reset": {
347+
case "reconnect": {
348348
messageQueue.clear(key);
349349
await sessionManager.closeSession(key);
350350
await interaction.reply("Session restarted. Prior context may be partially retained.");
351351
break;
352352
}
353+
// Clean destroys the session entirely — subprocess killed AND stored
354+
// state deleted. Next message starts a brand new session with no history.
355+
case "clean": {
356+
messageQueue.clear(key);
357+
await sessionManager.destroySession(key);
358+
await interaction.reply("Session cleaned. Fresh start.");
359+
break;
360+
}
353361
case "status": {
354362
const activeCount = sessionManager.getActiveCount();
355363
const memUsage = process.memoryUsage();
@@ -410,7 +418,8 @@ export async function createDiscordBot(
410418
// Register guild-scoped slash commands (instant, no 1-hour propagation delay)
411419
const commands = [
412420
new SlashCommandBuilder().setName("start").setDescription("Start the bot"),
413-
new SlashCommandBuilder().setName("reset").setDescription("Reset current session"),
421+
new SlashCommandBuilder().setName("reconnect").setDescription("Reconnect session (keeps context)"),
422+
new SlashCommandBuilder().setName("clean").setDescription("Clean session (fresh start)"),
414423
new SlashCommandBuilder().setName("status").setDescription("Show bot status"),
415424
];
416425
const rest = new REST().setToken(discordConfig.token);

0 commit comments

Comments
 (0)