From ec5491be092c5629eceb4b17e43f7d33320b825f Mon Sep 17 00:00:00 2001 From: jeffusion Date: Thu, 14 May 2026 11:26:19 +0800 Subject: [PATCH 1/2] feat(agent): sync model when switching agents When switching agents via the Telegram bot, the model now automatically syncs to the model configured for that agent in OpenCode config. If the agent has no model configured, the current model is preserved. Manual model switching remains independent and unaffected. --- src/agent/manager.ts | 20 +++++++++++++++++--- src/agent/types.ts | 4 ++++ src/bot/handlers/agent.ts | 15 ++++++++++++--- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/agent/manager.ts b/src/agent/manager.ts index 9b5cadf5..e28a88ae 100644 --- a/src/agent/manager.ts +++ b/src/agent/manager.ts @@ -26,9 +26,17 @@ export async function getAvailableAgents(): Promise { } // Filter out hidden agents and subagents (only show primary and all) - const filtered = agents.filter( - (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), - ); + const filtered = agents + .filter((agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all")) + .map((agent) => ({ + name: agent.name, + description: agent.description, + color: agent.color, + mode: agent.mode, + hidden: agent.hidden, + steps: agent.steps, + model: agent.model ? { modelID: agent.model.modelID, providerID: agent.model.providerID } : undefined, + })); logger.debug(`[AgentManager] Fetched ${filtered.length} available agents`); return filtered; @@ -138,6 +146,12 @@ export function selectAgent(agentName: string): void { setCurrentAgent(agentName); } +export async function getModelForAgent(agentName: string): Promise<{ modelID: string; providerID: string } | null> { + const agents = await getAvailableAgents(); + const agent = agents.find((a) => a.name === agentName); + return agent?.model ?? null; +} + /** * Get stored agent from settings (synchronous) * @returns Current agent name or default "build" diff --git a/src/agent/types.ts b/src/agent/types.ts index 460128b5..73e8d1f3 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -8,6 +8,10 @@ export interface AgentInfo { mode: "subagent" | "primary" | "all"; hidden?: boolean; steps?: number; + model?: { + modelID: string; + providerID: string; + }; } /** diff --git a/src/bot/handlers/agent.ts b/src/bot/handlers/agent.ts index 7b43837d..a8f8f0fb 100644 --- a/src/bot/handlers/agent.ts +++ b/src/bot/handlers/agent.ts @@ -1,7 +1,7 @@ import { Context, InlineKeyboard } from "grammy"; -import { selectAgent, getAvailableAgents, fetchCurrentAgent } from "../../agent/manager.js"; +import { selectAgent, getAvailableAgents, fetchCurrentAgent, getModelForAgent } from "../../agent/manager.js"; import { getAgentDisplayName } from "../../agent/types.js"; -import { getStoredModel } from "../../model/manager.js"; +import { getStoredModel, selectModel } from "../../model/manager.js"; import { formatVariantForButton } from "../../variant/manager.js"; import { logger } from "../../utils/logger.js"; import { createMainKeyboard } from "../utils/keyboard.js"; @@ -47,7 +47,16 @@ export async function handleAgentSelect(ctx: Context): Promise { // Select agent and persist selectAgent(agentName); - // Update keyboard manager state + const agentModel = await getModelForAgent(agentName); + if (agentModel) { + selectModel({ + providerID: agentModel.providerID, + modelID: agentModel.modelID, + variant: "default", + }); + await pinnedMessageManager.refreshContextLimit(); + } + keyboardManager.updateAgent(agentName); // Update Reply Keyboard with new agent, current model, and context From 58b72b26e0da6c8435bfd378dd9a4d4c0af7330c Mon Sep 17 00:00:00 2001 From: jeffusion Date: Fri, 15 May 2026 17:20:39 +0800 Subject: [PATCH 2/2] feat(sessions): consolidate session operations into preview panel Remove /rename as an independent command and unify all session operations (Select, Rename, Delete) into a session preview panel accessed via /sessions. This eliminates the fragmented entry points and ensures a consistent UX for session management. Key changes: - Remove /rename command, rename.ts, and RenameManager - Add preview panel with Select/Rename/Delete/Close actions - Add session deletion with confirmation dialog and detach cleanup - Add SSE session.deleted event handling for external deletions - Add fetchCurrentModelFromSession() to sync model on session switch - Fix post-attach ordering: model sync before context limit refresh - Fix agent sync: use clearCurrentAgent() instead of setCurrentAgent("") - Use sessions.deleted_external i18n key for external deletion notices --- src/background-session/tracker.ts | 14 + src/bot/commands/definitions.ts | 1 - src/bot/commands/rename.ts | 169 ------ src/bot/commands/sessions.ts | 440 ++++++++++++++- src/bot/index.ts | 72 ++- src/bot/middleware/interaction-guard.ts | 12 - src/i18n/de.ts | 35 +- src/i18n/en.ts | 34 +- src/i18n/es.ts | 37 +- src/i18n/fr.ts | 36 +- src/i18n/ru.ts | 35 +- src/i18n/zh.ts | 32 +- src/interaction/cleanup.ts | 6 +- src/interaction/guard.ts | 17 +- src/interaction/types.ts | 2 +- src/model/manager.ts | 76 ++- src/rename/manager.ts | 72 --- tests/bot/commands/abort.test.ts | 11 +- tests/bot/commands/rename.test.ts | 208 ------- tests/bot/commands/sessions.test.ts | 526 +++++++++++++++++- .../bot/middleware/interaction-guard.test.ts | 35 +- tests/helpers/reset-singleton-state.ts | 3 - tests/interaction/cleanup.test.ts | 9 +- tests/interaction/guard.test.ts | 43 +- tests/interaction/manager.test.ts | 2 +- tests/model/manager.test.ts | 178 ++++++ tests/rename/manager.test.ts | 45 -- 27 files changed, 1404 insertions(+), 746 deletions(-) delete mode 100644 src/bot/commands/rename.ts delete mode 100644 src/rename/manager.ts delete mode 100644 tests/bot/commands/rename.test.ts delete mode 100644 tests/rename/manager.test.ts diff --git a/src/background-session/tracker.ts b/src/background-session/tracker.ts index 3dfbb8eb..89616781 100644 --- a/src/background-session/tracker.ts +++ b/src/background-session/tracker.ts @@ -92,6 +92,20 @@ class BackgroundSessionTracker { case "session.idle": this.handleSessionIdle(event.properties as SessionIdleEventProperties, currentSessionId); break; + case "session.deleted": { + const props = event.properties as SessionInfoEventProperties; + const deletedId = props.info?.id; + if (deletedId) { + this.sessionTitles.delete(deletedId); + this.childSessionIds.delete(deletedId); + this.completedAssistantMessageIds.delete(deletedId); + this.pendingAssistantResponsesBySessionId.delete(deletedId); + this.questionRequestIds.delete(deletedId); + this.permissionRequestIds.delete(deletedId); + logger.debug(`[BackgroundSessionTracker] Cleaned up deleted session: id=${deletedId}`); + } + break; + } case "question.asked": this.handleRequestEvent( "question_asked", diff --git a/src/bot/commands/definitions.ts b/src/bot/commands/definitions.ts index 1b6ed4c4..5a19838d 100644 --- a/src/bot/commands/definitions.ts +++ b/src/bot/commands/definitions.ts @@ -31,7 +31,6 @@ const COMMAND_DEFINITIONS: BotCommandI18nDefinition[] = [ { command: "worktree", descriptionKey: "cmd.description.worktree" }, { command: "task", descriptionKey: "cmd.description.task" }, { command: "tasklist", descriptionKey: "cmd.description.tasklist" }, - { command: "rename", descriptionKey: "cmd.description.rename" }, { command: "commands", descriptionKey: "cmd.description.commands" }, { command: "skills", descriptionKey: "cmd.description.skills" }, { command: "mcps", descriptionKey: "cmd.description.mcps" }, diff --git a/src/bot/commands/rename.ts b/src/bot/commands/rename.ts deleted file mode 100644 index 100cfccb..00000000 --- a/src/bot/commands/rename.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { CommandContext, Context, InlineKeyboard } from "grammy"; -import { opencodeClient } from "../../opencode/client.js"; -import { getCurrentSession, setCurrentSession } from "../../session/manager.js"; -import { renameManager } from "../../rename/manager.js"; -import { interactionManager } from "../../interaction/manager.js"; -import { pinnedMessageManager } from "../../pinned/manager.js"; -import { logger } from "../../utils/logger.js"; -import { t } from "../../i18n/index.js"; - -function getCallbackMessageId(ctx: Context): number | null { - const message = ctx.callbackQuery?.message; - if (!message || !("message_id" in message)) { - return null; - } - - const messageId = (message as { message_id?: number }).message_id; - return typeof messageId === "number" ? messageId : null; -} - -function clearRenameInteraction(reason: string): void { - const state = interactionManager.getSnapshot(); - if (state?.kind === "rename") { - interactionManager.clear(reason); - } -} - -export async function renameCommand(ctx: CommandContext): Promise { - try { - const currentSession = getCurrentSession(); - - if (!currentSession) { - await ctx.reply(t("rename.no_session")); - return; - } - - const keyboard = new InlineKeyboard().text(t("rename.button.cancel"), "rename:cancel"); - - const message = await ctx.reply(t("rename.prompt", { title: currentSession.title }), { - reply_markup: keyboard, - }); - - renameManager.startWaiting(currentSession.id, currentSession.directory, currentSession.title); - renameManager.setMessageId(message.message_id); - interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { - sessionId: currentSession.id, - messageId: message.message_id, - }, - }); - - logger.info(`[RenameCommand] Waiting for new title for session: ${currentSession.id}`); - } catch (error) { - logger.error("[RenameCommand] Error starting rename flow:", error); - await ctx.reply(t("rename.error")); - } -} - -export async function handleRenameCancel(ctx: Context): Promise { - const data = ctx.callbackQuery?.data; - if (!data || data !== "rename:cancel") { - return false; - } - - logger.debug("[RenameHandler] Cancel callback received"); - - if (!renameManager.isWaitingForName()) { - clearRenameInteraction("rename_cancel_inactive"); - await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true }); - return true; - } - - const interactionState = interactionManager.getSnapshot(); - if (interactionState?.kind !== "rename") { - renameManager.clear(); - await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true }); - return true; - } - - const callbackMessageId = getCallbackMessageId(ctx); - if (!renameManager.isActiveMessage(callbackMessageId)) { - await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true }); - return true; - } - - renameManager.clear(); - clearRenameInteraction("rename_cancelled"); - - await ctx.answerCallbackQuery(); - await ctx.editMessageText(t("rename.cancelled")).catch(() => {}); - - return true; -} - -export async function handleRenameTextAnswer(ctx: Context): Promise { - if (!renameManager.isWaitingForName()) { - return false; - } - - const text = ctx.message?.text; - if (!text) { - return false; - } - - if (text.startsWith("/")) { - return false; - } - - const interactionState = interactionManager.getSnapshot(); - if (interactionState?.kind !== "rename") { - renameManager.clear(); - await ctx.reply(t("rename.inactive")); - return true; - } - - const sessionInfo = renameManager.getSessionInfo(); - if (!sessionInfo) { - renameManager.clear(); - clearRenameInteraction("rename_missing_session_info"); - return false; - } - - const newTitle = text.trim(); - if (!newTitle) { - await ctx.reply(t("rename.empty_title")); - return true; - } - - logger.info(`[RenameHandler] Renaming session ${sessionInfo.sessionId} to: ${newTitle}`); - - try { - const { data: updatedSession, error } = await opencodeClient.session.update({ - sessionID: sessionInfo.sessionId, - directory: sessionInfo.directory, - title: newTitle, - }); - - if (error || !updatedSession) { - throw error || new Error("Failed to update session"); - } - - setCurrentSession({ - id: sessionInfo.sessionId, - title: newTitle, - directory: sessionInfo.directory, - }); - - if (pinnedMessageManager.isInitialized()) { - await pinnedMessageManager.onSessionChange(sessionInfo.sessionId, newTitle); - } - - const messageId = renameManager.getMessageId(); - if (messageId && ctx.chat) { - await ctx.api.deleteMessage(ctx.chat.id, messageId).catch(() => {}); - } - - await ctx.reply(t("rename.success", { title: newTitle })); - - logger.info(`[RenameHandler] Session renamed successfully: ${newTitle}`); - } catch (error) { - logger.error("[RenameHandler] Error renaming session:", error); - await ctx.reply(t("rename.error")); - } - - renameManager.clear(); - clearRenameInteraction("rename_completed"); - return true; -} diff --git a/src/bot/commands/sessions.ts b/src/bot/commands/sessions.ts index 25daa595..adcd1c3f 100644 --- a/src/bot/commands/sessions.ts +++ b/src/bot/commands/sessions.ts @@ -2,11 +2,13 @@ import type { Bot } from "grammy"; import { CommandContext, Context } from "grammy"; import { InlineKeyboard } from "grammy"; import { opencodeClient } from "../../opencode/client.js"; -import { resolveProjectAgent } from "../../agent/manager.js"; -import { setCurrentSession, SessionInfo } from "../../session/manager.js"; +import { fetchCurrentAgent } from "../../agent/manager.js"; +import { clearCurrentAgent } from "../../settings/manager.js"; +import { clearSession, getCurrentSession, setCurrentSession, SessionInfo } from "../../session/manager.js"; import { getCurrentProject } from "../../settings/manager.js"; import { clearAllInteractionState } from "../../interaction/cleanup.js"; import { interactionManager } from "../../interaction/manager.js"; +import { fetchCurrentModelFromSession } from "../../model/manager.js"; import { keyboardManager } from "../../keyboard/manager.js"; import { appendInlineMenuCancelButton, @@ -18,15 +20,33 @@ import { logger } from "../../utils/logger.js"; import { safeBackgroundTask } from "../../utils/safe-background-task.js"; import { config } from "../../config.js"; import { getDateLocale, t } from "../../i18n/index.js"; -import { attachToSession } from "../../attach/service.js"; +import type { I18nKey } from "../../i18n/en.js"; +import { attachToSession, detachAttachedSession } from "../../attach/service.js"; +import { pinnedMessageManager } from "../../pinned/manager.js"; +import { foregroundSessionState } from "../../scheduled-task/foreground-state.js"; +import { assistantRunState } from "../assistant-run-state.js"; +import { clearPromptResponseMode } from "../handlers/prompt.js"; import { renderAssistantFinalPartsSafe } from "../utils/assistant-rendering.js"; import { sendRenderedBotPart } from "../utils/telegram-text.js"; const SESSION_CALLBACK_PREFIX = "session:"; const SESSION_PAGE_CALLBACK_PREFIX = "session:page:"; const BACKGROUND_SESSION_CALLBACK_PREFIX = "background-session:"; +const SESSION_PREVIEW_PREFIX = "session:preview:"; +const SESSION_SELECT_PREFIX = "session:select:"; +const SESSION_RENAME_PREFIX = "session:rename:"; +const SESSION_DELETE_PREFIX = "session:delete:"; +const SESSION_DELETE_CONFIRM_PREFIX = "session:delete:confirm:"; +const SESSION_DELETE_CANCEL_PREFIX = "session:delete:cancel:"; +const RENAME_CANCEL_CALLBACK = "rename:cancel"; const SESSION_FETCH_EXTRA_COUNT = 1; +type SessionTranslationParams = Record; + +function sessionT(key: string, params?: SessionTranslationParams): string { + return t(key as I18nKey, params); +} + type SessionListItem = { id: string; title: string; @@ -192,7 +212,7 @@ function buildSessionsKeyboard(pageData: SessionPage, pageSize: number): InlineK pageData.sessions.forEach((session, index) => { const date = new Date(session.time.created).toLocaleDateString(localeForDate); const label = `${pageStartIndex + index + 1}. ${session.title} (${date})`; - keyboard.text(label, `${SESSION_CALLBACK_PREFIX}${session.id}`).row(); + keyboard.text(label, `${SESSION_PREVIEW_PREFIX}${session.id}`).row(); }); if (pageData.page > 0) { @@ -210,6 +230,17 @@ function buildSessionsKeyboard(pageData: SessionPage, pageSize: number): InlineK return keyboard; } +function buildSessionPreviewKeyboard(sessionId: string): InlineKeyboard { + const keyboard = new InlineKeyboard(); + keyboard + .text(sessionT("sessions.button.select"), `${SESSION_SELECT_PREFIX}${sessionId}`) + .text(sessionT("sessions.button.rename"), `${SESSION_RENAME_PREFIX}${sessionId}`) + .row() + .text(sessionT("sessions.button.delete"), `${SESSION_DELETE_PREFIX}${sessionId}`) + .text(sessionT("sessions.button.close"), "inline:cancel:session"); + return keyboard; +} + export async function sessionsCommand(ctx: CommandContext) { try { if (isForegroundBusy()) { @@ -321,11 +352,32 @@ async function selectSessionById( if (ctx.chat) { const chatId = ctx.chat.id; - const currentAgent = await resolveProjectAgent(); + clearCurrentAgent(); + + const currentAgent = await fetchCurrentAgent(); keyboardManager.updateAgent(currentAgent); - const contextInfo = keyboardManager.getContextInfo(); + const currentModel = await fetchCurrentModelFromSession(); + keyboardManager.updateModel(currentModel); + + // Refresh context limit AFTER model sync — fetchContextLimit() reads getStoredModel() + await pinnedMessageManager.refreshContextLimit(); + + // Re-read tokensUsed from history — onSessionChange() was called inside + // attachToSession() with the OLD model, so loadContextFromHistory() may have + // used the wrong tokensLimit when building the pinned message. Reload to + // ensure the new model's limit is reflected in the pinned message. + const currentSession = getCurrentSession(); + const currentProjectWorktree = currentProject.worktree; + if (currentSession) { + await pinnedMessageManager.loadContextFromHistory( + currentSession.id, + currentProjectWorktree, + ); + } + + const contextInfo = pinnedMessageManager.getContextInfo(); if (contextInfo) { keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit); } @@ -482,12 +534,58 @@ export async function handleSessionSelect(ctx: Context, deps: SessionSelectDeps) return true; } - await selectSessionById(ctx, deps, sessionId, { - source: "menu", - deleteCallbackMessage: true, - removeCallbackReplyMarkup: false, - postSelectAction: "preview", - }); + if (callbackQuery.data.startsWith(SESSION_PREVIEW_PREFIX)) { + const previewSessionId = callbackQuery.data.slice(SESSION_PREVIEW_PREFIX.length); + if (!previewSessionId) { + await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + return true; + } + await handleSessionPreviewCallback(ctx, deps, previewSessionId, currentProject.worktree); + } else if (callbackQuery.data.startsWith(SESSION_SELECT_PREFIX)) { + const selectSessionId = callbackQuery.data.slice(SESSION_SELECT_PREFIX.length); + if (!selectSessionId) { + await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + return true; + } + await selectSessionById(ctx, deps, selectSessionId, { + source: "menu", + deleteCallbackMessage: false, + removeCallbackReplyMarkup: true, + postSelectAction: "none", + }); + } else if (callbackQuery.data.startsWith(SESSION_RENAME_PREFIX)) { + const renameSessionId = callbackQuery.data.slice(SESSION_RENAME_PREFIX.length); + if (!renameSessionId) { + await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + return true; + } + await handleSessionRenameCallback(ctx, renameSessionId, currentProject.worktree); + } else if (callbackQuery.data.startsWith(SESSION_DELETE_PREFIX)) { + if (callbackQuery.data.startsWith(SESSION_DELETE_CANCEL_PREFIX)) { + await handleSessionDeleteCancelCallback(ctx, currentProject.worktree); + } else if (callbackQuery.data.startsWith(SESSION_DELETE_CONFIRM_PREFIX)) { + const confirmSessionId = callbackQuery.data.slice(SESSION_DELETE_CONFIRM_PREFIX.length); + if (!confirmSessionId) { + await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + return true; + } + await handleSessionDeleteConfirmCallback(ctx, deps, confirmSessionId, currentProject.worktree); + } else { + const deleteSessionId = callbackQuery.data.slice(SESSION_DELETE_PREFIX.length); + if (!deleteSessionId) { + await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + return true; + } + await handleSessionDeleteCallback(ctx, deleteSessionId, currentProject.worktree); + } + } else { + await selectSessionById(ctx, deps, sessionId, { + source: "menu", + deleteCallbackMessage: true, + removeCallbackReplyMarkup: false, + postSelectAction: "preview", + }); + } } catch (error) { clearAllInteractionState("session_select_error"); logger.error("[Sessions] Error selecting session:", error); @@ -498,6 +596,324 @@ export async function handleSessionSelect(ctx: Context, deps: SessionSelectDeps) return true; } +async function handleSessionPreviewCallback( + ctx: Context, + _deps: SessionSelectDeps, + sessionId: string, + directory: string, +): Promise { + const { data: session, error } = await opencodeClient.session.get({ + sessionID: sessionId, + directory, + }); + + if (error || !session) { + await ctx.answerCallbackQuery({ text: t("sessions.select_error"), show_alert: true }); + return; + } + + const previewItems = await loadSessionPreview(sessionId, directory); + const previewText = formatSessionPreview(session.title, previewItems); + const keyboard = buildSessionPreviewKeyboard(sessionId); + + try { + await ctx.editMessageText(previewText, { reply_markup: keyboard }); + } catch (err) { + logger.warn("[Sessions] Failed to edit message for preview, sending new:", err); + await ctx.reply(previewText, { reply_markup: keyboard }); + } + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Preview shown for session: id=${sessionId}, title="${session.title}"`); +} + +async function handleSessionRenameCallback( + ctx: Context, + sessionId: string, + directory: string, +): Promise { + const { data: session, error } = await opencodeClient.session.get({ + sessionID: sessionId, + directory, + }); + + if (error || !session) { + await ctx.answerCallbackQuery({ text: t("sessions.select_error"), show_alert: true }); + return; + } + + const keyboard = new InlineKeyboard().text(sessionT("sessions.rename.cancel"), RENAME_CANCEL_CALLBACK); + const text = sessionT("sessions.rename.prompt", { title: session.title }); + + try { + await ctx.editMessageText(text, { reply_markup: keyboard }); + } catch (err) { + logger.warn("[Sessions] Failed to edit message for rename prompt:", err); + } + + interactionManager.start({ + kind: "custom", + expectedInput: "text", + metadata: { + action: "session_rename", + sessionId, + directory, + currentTitle: session.title, + }, + }); + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Rename flow started for session: id=${sessionId}`); +} + +export async function handleRenameCancelCallback(ctx: Context): Promise { + const data = ctx.callbackQuery?.data; + if (data !== RENAME_CANCEL_CALLBACK) { + return false; + } + + const state = interactionManager.getSnapshot(); + if (!state || state.kind !== "custom" || state.metadata?.action !== "session_rename") { + await ctx.answerCallbackQuery({ text: t("callback.processing_error") }); + return true; + } + + const { sessionId, directory } = state.metadata as { + sessionId: string; + directory: string; + currentTitle: string; + }; + + interactionManager.clear("rename_cancelled"); + + const { data: session } = await opencodeClient.session.get({ + sessionID: sessionId, + directory, + }); + + if (session && ctx.chat) { + const previewItems = await loadSessionPreview(sessionId, directory); + const previewText = formatSessionPreview(session.title, previewItems); + const keyboard = buildSessionPreviewKeyboard(sessionId); + try { + await ctx.editMessageText(previewText, { reply_markup: keyboard }); + } catch (err) { + logger.warn("[Sessions] Failed to restore preview after rename cancel:", err); + } + } + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Rename cancelled for session: id=${sessionId}`); + return true; +} + +export async function handleRenameTextAnswer(ctx: Context): Promise { + const state = interactionManager.getSnapshot(); + if (!state || state.kind !== "custom" || state.metadata?.action !== "session_rename") { + return false; + } + + const text = ctx.message?.text; + if (!text || text.startsWith("/")) { + return false; + } + + const { sessionId, directory } = state.metadata as { + sessionId: string; + directory: string; + currentTitle: string; + }; + + const newTitle = text.trim(); + if (!newTitle) { + await ctx.reply(sessionT("sessions.rename.empty")); + return true; + } + + logger.info(`[Sessions] Renaming session ${sessionId} to: ${newTitle}`); + + try { + const { data: updatedSession, error } = await opencodeClient.session.update({ + sessionID: sessionId, + directory, + title: newTitle, + }); + + if (error || !updatedSession) { + throw error || new Error("Failed to update session"); + } + + const currentSession = getCurrentSession(); + if (currentSession && currentSession.id === sessionId) { + setCurrentSession({ id: sessionId, title: newTitle, directory }); + if (pinnedMessageManager.isInitialized()) { + await pinnedMessageManager.onSessionChange(sessionId, newTitle); + } + } + + interactionManager.clear("rename_completed"); + await ctx.reply(sessionT("sessions.rename.success", { title: newTitle })); + logger.info(`[Sessions] Session renamed successfully: ${newTitle}`); + } catch (error) { + logger.error("[Sessions] Error renaming session:", error); + await ctx.reply(sessionT("sessions.rename.error")); + } + + return true; +} + +async function handleSessionDeleteCallback( + ctx: Context, + sessionId: string, + directory: string, +): Promise { + const { data: session, error } = await opencodeClient.session.get({ + sessionID: sessionId, + directory, + }); + + if (error || !session) { + await ctx.answerCallbackQuery({ text: t("sessions.select_error"), show_alert: true }); + return; + } + + const text = sessionT("sessions.delete.confirm", { title: session.title }); + const keyboard = new InlineKeyboard() + .text(sessionT("sessions.delete.yes"), `${SESSION_DELETE_CONFIRM_PREFIX}${sessionId}`) + .text(sessionT("sessions.delete.no"), `${SESSION_DELETE_CANCEL_PREFIX}${sessionId}`); + + try { + await ctx.editMessageText(text, { reply_markup: keyboard }); + } catch (err) { + logger.warn("[Sessions] Failed to edit message for delete confirm:", err); + } + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Delete confirmation shown for session: id=${sessionId}`); +} + +async function handleSessionDeleteConfirmCallback( + ctx: Context, + _deps: SessionSelectDeps, + sessionId: string, + directory: string, +): Promise { + try { + const { data: sessionBeforeDelete, error: getError } = await opencodeClient.session.get({ + sessionID: sessionId, + directory, + }); + if (getError) { + logger.warn("[Sessions] Failed to fetch session before delete:", getError); + } + const deletedTitle = sessionBeforeDelete?.title ?? sessionId; + + const { error } = await opencodeClient.session.delete({ + sessionID: sessionId, + directory, + }); + + if (error) { + const isNotFound = (error as { status?: number })?.status === 404; + const errorMessage = isNotFound + ? sessionT("sessions.delete.not_found") + : sessionT("sessions.delete.error"); + await ctx.editMessageText(errorMessage).catch(() => {}); + await ctx.answerCallbackQuery({ text: errorMessage, show_alert: true }); + return; + } + + const currentSession = getCurrentSession(); + if (currentSession && currentSession.id === sessionId) { + detachAttachedSession("session_deleted"); + clearPromptResponseMode(sessionId); + foregroundSessionState.markIdle(sessionId); + assistantRunState.clearRun(sessionId, "session_deleted"); + clearAllInteractionState("session_deleted"); + clearSession(); + + if (pinnedMessageManager.isInitialized()) { + try { + await pinnedMessageManager.clear(); + } catch (err) { + logger.error("[Sessions] Failed to clear pinned message after delete:", err); + } + } + + if (ctx.chat) { + keyboardManager.initialize(ctx.api, ctx.chat.id); + } + + await pinnedMessageManager.refreshContextLimit(); + const contextLimit = pinnedMessageManager.getContextLimit(); + keyboardManager.updateContext(0, contextLimit); + } else { + clearAllInteractionState("session_deleted_other"); + } + + const successMessage = sessionT("sessions.delete.success", { title: deletedTitle }); + try { + await ctx.editMessageText(successMessage); + } catch (err) { + logger.warn("[Sessions] Failed to edit message after delete:", err); + await ctx.reply(successMessage); + } + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Session deleted: id=${sessionId}`); + } catch (error) { + logger.error("[Sessions] Error deleting session:", error); + try { + await ctx.editMessageText(sessionT("sessions.delete.error")); + } catch (err) { + logger.warn("[Sessions] Failed to edit message after delete error:", err); + await ctx.reply(sessionT("sessions.delete.error")); + } + await ctx + .answerCallbackQuery({ text: sessionT("sessions.delete.error"), show_alert: true }) + .catch(() => {}); + } +} + +async function handleSessionDeleteCancelCallback(ctx: Context, directory: string): Promise { + const data = ctx.callbackQuery?.data; + const sessionId = data?.startsWith(SESSION_DELETE_CANCEL_PREFIX) + ? data.slice(SESSION_DELETE_CANCEL_PREFIX.length) + : null; + + if (sessionId) { + const { data: session } = await opencodeClient.session.get({ + sessionID: sessionId, + directory, + }); + if (session) { + const previewItems = await loadSessionPreview(sessionId, directory); + const previewText = formatSessionPreview(session.title, previewItems); + const keyboard = buildSessionPreviewKeyboard(sessionId); + try { + await ctx.editMessageText(previewText, { reply_markup: keyboard }); + } catch (err) { + logger.warn("[Sessions] Failed to restore preview after delete cancel:", err); + } + } else { + try { + await ctx.editMessageReplyMarkup(); + } catch (err) { + logger.debug("[Sessions] Failed to remove reply markup after delete cancel:", err); + } + } + } else { + try { + await ctx.editMessageReplyMarkup(); + } catch (err) { + logger.debug("[Sessions] Failed to remove reply markup after delete cancel:", err); + } + } + + await ctx.answerCallbackQuery(); + logger.info(`[Sessions] Delete cancelled for session: id=${sessionId}`); +} + type SessionPreviewItem = { role: "user" | "assistant"; text: string; diff --git a/src/bot/index.ts b/src/bot/index.ts index ad93d72d..e86ce8a4 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -18,6 +18,8 @@ import { import { buildBackgroundSessionOpenKeyboard, handleBackgroundSessionOpen, + handleRenameCancelCallback, + handleRenameTextAnswer, handleSessionSelect, sessionsCommand, } from "./commands/sessions.js"; @@ -30,7 +32,6 @@ import { abortCommand } from "./commands/abort.js"; import { detachCommand } from "./commands/detach.js"; import { opencodeStartCommand } from "./commands/opencode-start.js"; import { opencodeStopCommand } from "./commands/opencode-stop.js"; -import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./commands/rename.js"; import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands/task.js"; import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js"; import { @@ -66,7 +67,7 @@ import { summaryAggregator } from "../summary/aggregator.js"; import { formatToolInfo } from "../summary/formatter.js"; import { renderSubagentCards } from "../summary/subagent-formatter.js"; import { ToolMessageBatcher } from "../summary/tool-message-batcher.js"; -import { getCurrentSession } from "../session/manager.js"; +import { clearSession, getCurrentSession } from "../session/manager.js"; import { ingestSessionInfoForCache } from "../session/cache-manager.js"; import { logger } from "../utils/logger.js"; import { safeBackgroundTask } from "../utils/safe-background-task.js"; @@ -103,6 +104,7 @@ import type { StreamingMessagePayload } from "./streaming/response-streamer.js"; import { ToolCallStreamer, type ToolStreamKey } from "./streaming/tool-call-streamer.js"; import { attachManager } from "../attach/manager.js"; import { + detachAttachedSession, markAttachedSessionBusy, markAttachedSessionIdle, restoreAttachedCurrentSession, @@ -1044,7 +1046,64 @@ async function ensureEventSubscription(directory: string): Promise { } } - if (config.bot.trackBackgroundSessions) { + if (event.type === "session.deleted") { + const props = event.properties as { info?: { id?: string; title?: string } }; + const deletedId = props.info?.id; + const deletedTitle = props.info?.title ?? deletedId ?? "unknown"; + + if (deletedId) { + if (config.bot.trackBackgroundSessions) { + backgroundSessionTracker.processEvent(event, getCurrentSession()?.id ?? null); + } + + const currentSession = getCurrentSession(); + if (currentSession && currentSession.id === deletedId) { + logger.info(`[Bot] Current session was deleted externally: id=${deletedId}`); + + detachAttachedSession("session_deleted_external"); + + try { + clearPromptResponseMode(deletedId); + } catch (e) { + logger.debug("[Bot] Failed to clear prompt response mode after external delete:", e); + } + + foregroundSessionState.markIdle(deletedId); + assistantRunState.clearRun(deletedId, "session_deleted_external"); + clearAllInteractionState("session_deleted_external"); + clearSession(); + + const bot = botInstance; + const chatId = chatIdInstance; + if (bot && chatId) { + void (async () => { + if (pinnedMessageManager.isInitialized()) { + pinnedMessageManager.clear().catch((err: unknown) => { + logger.error("[Bot] Failed to clear pinned message after external delete:", err); + }); + } + + keyboardManager.initialize(bot.api, chatId); + + await pinnedMessageManager.refreshContextLimit(); + const contextLimit = pinnedMessageManager.getContextLimit(); + keyboardManager.updateContext(0, contextLimit); + + try { + await bot.api.sendMessage( + chatId, + t("sessions.deleted_external", { title: deletedTitle }), + ); + } catch (err) { + logger.error("[Bot] Failed to send session deleted notification:", err); + } + })(); + } + } + } + } + + if (config.bot.trackBackgroundSessions && event.type !== "session.deleted") { backgroundSessionTracker.processEvent(event, getCurrentSession()?.id ?? null); } @@ -1174,7 +1233,6 @@ export function createBot(): Bot { bot.command("detach", detachCommand); bot.command("task", taskCommand); bot.command("tasklist", taskListCommand); - bot.command("rename", renameCommand); bot.command("commands", commandsCommand); bot.command("skills", skillsCommand); bot.command("mcps", mcpsCommand); @@ -1201,6 +1259,7 @@ export function createBot(): Bot { clearOpenPathIndex(); clearLsPathIndex(); } + const handledRenameCancel = await handleRenameCancelCallback(ctx); const handledSession = await handleSessionSelect(ctx, { bot, ensureEventSubscription }); const handledProject = await handleProjectSelect(ctx, { ensureEventSubscription }); const handledWorktree = await handleWorktreeCallback(ctx, { ensureEventSubscription }); @@ -1214,18 +1273,18 @@ export function createBot(): Bot { const handledCompactConfirm = await handleCompactConfirm(ctx); const handledTask = await handleTaskCallback(ctx); const handledTaskList = await handleTaskListCallback(ctx); - const handledRenameCancel = await handleRenameCancel(ctx); const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription }); const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription }); const handledMcps = await handleMcpsCallback(ctx); logger.debug( - `[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}, mcps=${handledMcps}`, + `[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, renameCancel=${handledRenameCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, commands=${handledCommands}, skills=${handledSkills}, mcps=${handledMcps}`, ); if ( !handledBackgroundSession && !handledInlineCancel && + !handledRenameCancel && !handledSession && !handledProject && !handledWorktree && @@ -1239,7 +1298,6 @@ export function createBot(): Bot { !handledCompactConfirm && !handledTask && !handledTaskList && - !handledRenameCancel && !handledCommands && !handledSkills && !handledMcps diff --git a/src/bot/middleware/interaction-guard.ts b/src/bot/middleware/interaction-guard.ts index b5404ad2..247e7c20 100644 --- a/src/bot/middleware/interaction-guard.ts +++ b/src/bot/middleware/interaction-guard.ts @@ -45,18 +45,6 @@ function getInteractionBlockedMessage( } } - if (interactionKind === "rename") { - switch (reason) { - case "command_not_allowed": - return t("rename.blocked.command_not_allowed"); - case "expected_callback": - case "expected_command": - case "expected_text": - default: - return t("rename.blocked.expected_name"); - } - } - if (interactionKind === "task") { switch (reason) { case "command_not_allowed": diff --git a/src/i18n/de.ts b/src/i18n/de.ts index b94661d7..00ff700d 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -171,6 +171,25 @@ export const de: I18nDictionary = { "sessions.preview.title": "Letzte Nachrichten:", "sessions.preview.you": "Du:", "sessions.preview.agent": "Agent:", + "sessions.button.select": "✅ Auswählen", + "sessions.button.rename": "✏️ Umbenennen", + "sessions.button.delete": "🗑 Löschen", + "sessions.button.close": "✖ Schließen", + "sessions.current_session": "Bereits ausgewählt", + "sessions.rename.prompt": "📝 Neuen Titel eingeben:\n\nAktuell: {title}", + "sessions.rename.cancel": "❌ Abbrechen", + "sessions.rename.empty": "⚠️ Titel darf nicht leer sein.", + "sessions.rename.success": "✅ Sitzung umbenannt zu: {title}", + "sessions.rename.error": "🔴 Sitzung konnte nicht umbenannt werden.", + "sessions.delete.confirm": + '⚠️ Sitzung löschen\n\n"{title}"\nAlle Nachrichten und Verlauf werden dauerhaft entfernt.', + "sessions.delete.yes": "✅ Ja, löschen", + "sessions.delete.no": "❌ Nein", + "sessions.delete.success": "✅ Sitzung gelöscht: {title}", + "sessions.delete.not_found": + "🔴 Sitzung nicht gefunden. Möglicherweise wurde sie bereits gelöscht.", + "sessions.delete.error": "🔴 Sitzung konnte nicht gelöscht werden.", + "sessions.deleted_external": "🗑 Sitzung wurde gelöscht: {title}\nSie wurden abgemeldet.", "attach.project_not_selected": "🏗 Projekt ist nicht ausgewählt.\n\nWähle zuerst ein Projekt mit /projects.", @@ -406,20 +425,6 @@ export const de: I18nDictionary = { "runtime.wizard.tty_required": "Der interaktive Assistent erfordert ein TTY-Terminal. Führe `opencode-telegram config` in einer interaktiven Shell aus.", - "rename.no_session": "⚠️ Keine aktive Sitzung. Erstelle oder wähle zuerst eine Sitzung.", - "rename.prompt": "📝 Neuen Titel für die Sitzung eingeben:\n\nAktuell: {title}", - "rename.empty_title": "⚠️ Titel darf nicht leer sein.", - "rename.success": "✅ Sitzung umbenannt in: {title}", - "rename.error": "🔴 Sitzung konnte nicht umbenannt werden.", - "rename.cancelled": "❌ Umbenennen abgebrochen.", - "rename.inactive_callback": "Umbenennen-Anfrage ist inaktiv", - "rename.inactive": "⚠️ Umbenennen-Anfrage ist nicht aktiv. Starte /rename erneut.", - "rename.blocked.expected_name": - "⚠️ Sende den neuen Sitzungsnamen als Text oder tippe in der Umbenennen-Nachricht auf Abbrechen.", - "rename.blocked.command_not_allowed": - "⚠️ Dieser Befehl ist nicht verfügbar, solange beim Umbenennen auf einen neuen Namen gewartet wird.", - "rename.button.cancel": "❌ Abbrechen", - "task.prompt.schedule": "⏰ Sende den Zeitplan der Aufgabe in natürlicher Sprache.\n\nBeispiele:\n- alle 5 Minuten\n- jeden Tag um 17:00\n- morgen um 12:00", "task.schedule_empty": "⚠️ Der Zeitplan darf nicht leer sein.", @@ -540,8 +545,6 @@ export const de: I18nDictionary = { "mcps.button.back": "⬅️ Back", "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", - "cmd.description.rename": "Aktuelle Sitzung umbenennen", - "legacy.models.fetch_error": "🔴 Modellliste konnte nicht geladen werden. Prüfe den Serverstatus mit /status.", "legacy.models.empty": "📋 Keine verfügbaren Modelle. Konfiguriere Provider in OpenCode.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index b88c7deb..93023215 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -163,6 +163,24 @@ export const en = { "sessions.preview.title": "Recent messages:", "sessions.preview.you": "You:", "sessions.preview.agent": "Agent:", + "sessions.button.select": "✅ Select", + "sessions.button.rename": "✏️ Rename", + "sessions.button.delete": "🗑 Delete", + "sessions.button.close": "✖ Close", + "sessions.current_session": "Already selected", + "sessions.rename.prompt": "📝 Enter new title:\n\nCurrent: {title}", + "sessions.rename.cancel": "❌ Cancel", + "sessions.rename.empty": "⚠️ Title cannot be empty.", + "sessions.rename.success": "✅ Session renamed to: {title}", + "sessions.rename.error": "🔴 Failed to rename session.", + "sessions.delete.confirm": + '⚠️ Delete Session\n\n"{title}"\nAll messages and history will be permanently removed.', + "sessions.delete.yes": "✅ Yes, Delete", + "sessions.delete.no": "❌ No", + "sessions.delete.success": "✅ Session deleted: {title}", + "sessions.delete.not_found": "🔴 Session not found. It may have been deleted already.", + "sessions.delete.error": "🔴 Failed to delete session.", + "sessions.deleted_external": "🗑 Session was deleted: {title}\nYou have been detached.", "attach.project_not_selected": "🏗 Project is not selected.\n\nFirst select a project with /projects.", @@ -389,20 +407,6 @@ export const en = { "runtime.wizard.tty_required": "Interactive wizard requires a TTY terminal. Run `opencode-telegram config` in an interactive shell.", - "rename.no_session": "⚠️ No active session. Create or select a session first.", - "rename.prompt": "📝 Enter new title for session:\n\nCurrent: {title}", - "rename.empty_title": "⚠️ Title cannot be empty.", - "rename.success": "✅ Session renamed to: {title}", - "rename.error": "🔴 Failed to rename session.", - "rename.cancelled": "❌ Rename cancelled.", - "rename.inactive_callback": "Rename request is inactive", - "rename.inactive": "⚠️ Rename request is not active. Run /rename again.", - "rename.blocked.expected_name": - "⚠️ Enter a new session name as text or tap Cancel in rename message.", - "rename.blocked.command_not_allowed": - "⚠️ This command is not available while rename is waiting for a new name.", - "rename.button.cancel": "❌ Cancel", - "task.prompt.schedule": "⏰ Send the task schedule in natural language.\n\nExamples:\n- every 5 minutes\n- every day at 17:00\n- tomorrow at 12:00", "task.schedule_empty": "⚠️ Schedule cannot be empty.", @@ -518,8 +522,6 @@ export const en = { "mcps.button.back": "⬅️ Back", "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", - "cmd.description.rename": "Rename current session", - "legacy.models.fetch_error": "🔴 Failed to get models list. Check server status with /status.", "legacy.models.empty": "📋 No available models. Configure providers in OpenCode.", "legacy.models.header": "📋 Available models:\n\n", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index f0805771..8c9fca9a 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -170,6 +170,26 @@ export const es: I18nDictionary = { "sessions.preview.title": "Mensajes recientes:", "sessions.preview.you": "Tú:", "sessions.preview.agent": "Agente:", + "sessions.button.select": "✅ Seleccionar", + "sessions.button.rename": "✏️ Renombrar", + "sessions.button.delete": "🗑 Eliminar", + "sessions.button.close": "✖ Cerrar", + "sessions.current_session": "Ya seleccionada", + "sessions.rename.prompt": "📝 Ingrese nuevo título:\n\nActual: {title}", + "sessions.rename.cancel": "❌ Cancelar", + "sessions.rename.empty": "⚠️ El título no puede estar vacío.", + "sessions.rename.success": "✅ Sesión renombrada a: {title}", + "sessions.rename.error": "🔴 Error al renombrar la sesión.", + "sessions.delete.confirm": + '⚠️ Eliminar sesión\n\n"{title}"\nTodos los mensajes e historial se eliminarán permanentemente.', + "sessions.delete.yes": "✅ Sí, eliminar", + "sessions.delete.no": "❌ No", + "sessions.delete.success": "✅ Sesión eliminada: {title}", + "sessions.delete.not_found": + "🔴 Sesión no encontrada. Puede que ya haya sido eliminada.", + "sessions.delete.error": "🔴 Error al eliminar la sesión.", + "sessions.deleted_external": + "🗑 La sesión fue eliminada: {title}\nHas sido desconectado.", "attach.project_not_selected": "🏗 No hay un proyecto seleccionado.\n\nPrimero selecciona un proyecto con /projects.", @@ -404,21 +424,6 @@ export const es: I18nDictionary = { "runtime.wizard.tty_required": "El asistente interactivo requiere un terminal TTY. Ejecuta `opencode-telegram config` en una shell interactiva.", - "rename.no_session": "⚠️ No hay una sesión activa. Crea o selecciona una sesión primero.", - "rename.prompt": "📝 Introduce un nuevo título para la sesión:\n\nActual: {title}", - "rename.empty_title": "⚠️ El título no puede estar vacío.", - "rename.success": "✅ Sesión renombrada a: {title}", - "rename.error": "🔴 No se pudo renombrar la sesión.", - "rename.cancelled": "❌ Cambio de nombre cancelado.", - "rename.inactive_callback": "La solicitud de cambio de nombre está inactiva", - "rename.inactive": - "⚠️ La solicitud de cambio de nombre no está activa. Ejecuta /rename otra vez.", - "rename.blocked.expected_name": - "⚠️ Introduce el nuevo nombre de la sesión como texto o toca Cancelar en el mensaje de cambio de nombre.", - "rename.blocked.command_not_allowed": - "⚠️ Este comando no está disponible mientras el cambio de nombre espera un nuevo nombre.", - "rename.button.cancel": "❌ Cancelar", - "task.prompt.schedule": "⏰ Envía el horario de la tarea en lenguaje natural.\n\nEjemplos:\n- cada 5 minutos\n- cada día a las 17:00\n- mañana a las 12:00", "task.schedule_empty": "⚠️ El horario no puede estar vacío.", @@ -539,8 +544,6 @@ export const es: I18nDictionary = { "mcps.button.back": "⬅️ Back", "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", - "cmd.description.rename": "Renombrar la sesión actual", - "legacy.models.fetch_error": "🔴 No se pudo obtener la lista de modelos. Revisa el estado del servidor con /status.", "legacy.models.empty": "📋 No hay modelos disponibles. Configura los proveedores en OpenCode.", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 0a95b40f..78b00564 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -172,6 +172,26 @@ export const fr: I18nDictionary = { "sessions.preview.title": "Messages récents :", "sessions.preview.you": "Vous :", "sessions.preview.agent": "Agent :", + "sessions.button.select": "✅ Sélectionner", + "sessions.button.rename": "✏️ Renommer", + "sessions.button.delete": "🗑 Supprimer", + "sessions.button.close": "✖ Fermer", + "sessions.current_session": "Déjà sélectionnée", + "sessions.rename.prompt": "📝 Entrez le nouveau titre :\n\nActuel : {title}", + "sessions.rename.cancel": "❌ Annuler", + "sessions.rename.empty": "⚠️ Le titre ne peut pas être vide.", + "sessions.rename.success": "✅ Session renommée en : {title}", + "sessions.rename.error": "🔴 Échec du renommage de la session.", + "sessions.delete.confirm": + '⚠️ Supprimer la session\n\n"{title}"\nTous les messages et l\'historique seront définitivement supprimés.', + "sessions.delete.yes": "✅ Oui, supprimer", + "sessions.delete.no": "❌ Non", + "sessions.delete.success": "✅ Session supprimée : {title}", + "sessions.delete.not_found": + "🔴 Session introuvable. Elle a peut-être déjà été supprimée.", + "sessions.delete.error": "🔴 Échec de la suppression de la session.", + "sessions.deleted_external": + "🗑 La session a été supprimée : {title}\nVous avez été détaché.", "attach.project_not_selected": "🏗 Aucun projet sélectionné.\n\nSélectionnez d'abord un projet avec /projects.", @@ -409,20 +429,6 @@ export const fr: I18nDictionary = { "runtime.wizard.tty_required": "L'assistant interactif nécessite un terminal TTY. Exécutez `opencode-telegram config` dans un shell interactif.", - "rename.no_session": "⚠️ Aucune session active. Créez ou sélectionnez d'abord une session.", - "rename.prompt": "📝 Entrez le nouveau titre de la session :\n\nActuel : {title}", - "rename.empty_title": "⚠️ Le titre ne peut pas être vide.", - "rename.success": "✅ Session renommée en : {title}", - "rename.error": "🔴 Impossible de renommer la session.", - "rename.cancelled": "❌ Renommage annulé.", - "rename.inactive_callback": "La demande de renommage est inactive", - "rename.inactive": "⚠️ La demande de renommage n'est pas active. Exécutez /rename à nouveau.", - "rename.blocked.expected_name": - "⚠️ Entrez le nouveau nom de la session sous forme de texte ou appuyez sur Annuler dans le message de renommage.", - "rename.blocked.command_not_allowed": - "⚠️ Cette commande n'est pas disponible tant que le renommage attend un nouveau nom.", - "rename.button.cancel": "❌ Annuler", - "task.prompt.schedule": "⏰ Envoyez le planning de la tâche en langage naturel.\n\nExemples :\n- toutes les 5 minutes\n- chaque jour à 17:00\n- demain à 12:00", "task.schedule_empty": "⚠️ Le planning ne peut pas être vide.", @@ -541,8 +547,6 @@ export const fr: I18nDictionary = { "mcps.button.back": "⬅️ Back", "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", - "cmd.description.rename": "Renommer la session actuelle", - "legacy.models.fetch_error": "🔴 Impossible de récupérer la liste des modèles. Vérifiez l'état du serveur avec /status.", "legacy.models.empty": "📋 Aucun modèle disponible. Configurez les fournisseurs dans OpenCode.", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 57789800..0c684b93 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -163,6 +163,25 @@ export const ru: I18nDictionary = { "sessions.preview.title": "Последние сообщения:", "sessions.preview.you": "Вы:", "sessions.preview.agent": "Агент:", + "sessions.button.select": "✅ Выбрать", + "sessions.button.rename": "✏️ Переименовать", + "sessions.button.delete": "🗑 Удалить", + "sessions.button.close": "✖ Закрыть", + "sessions.current_session": "Уже выбрана", + "sessions.rename.prompt": "📝 Введите новый заголовок:\n\nТекущий: {title}", + "sessions.rename.cancel": "❌ Отмена", + "sessions.rename.empty": "⚠️ Заголовок не может быть пустым.", + "sessions.rename.success": "✅ Сессия переименована в: {title}", + "sessions.rename.error": "🔴 Не удалось переименовать сессию.", + "sessions.delete.confirm": + '⚠️ Удалить сессию\n\n"{title}"\nВсе сообщения и история будут удалены безвозвратно.', + "sessions.delete.yes": "✅ Да, удалить", + "sessions.delete.no": "❌ Нет", + "sessions.delete.success": "✅ Сессия удалена: {title}", + "sessions.delete.not_found": + "🔴 Сессия не найдена. Возможно, она уже была удалена.", + "sessions.delete.error": "🔴 Не удалось удалить сессию.", + "sessions.deleted_external": "🗑 Сессия была удалена: {title}\nВы были отсоединены.", "attach.project_not_selected": "🏗 Проект не выбран.\n\nСначала выберите проект командой /projects.", @@ -391,20 +410,6 @@ export const ru: I18nDictionary = { "runtime.wizard.tty_required": "Интерактивный wizard требует TTY-терминал. Запустите `opencode-telegram config` в интерактивной оболочке.", - "rename.no_session": "⚠️ Нет активной сессии. Сначала создайте или выберите сессию.", - "rename.prompt": "📝 Введите новое название сессии:\n\nТекущее: {title}", - "rename.empty_title": "⚠️ Название не может быть пустым.", - "rename.success": "✅ Сессия переименована в: {title}", - "rename.error": "🔴 Не удалось переименовать сессию.", - "rename.cancelled": "❌ Переименование отменено.", - "rename.inactive_callback": "Запрос переименования неактивен", - "rename.inactive": "⚠️ Запрос переименования неактивен. Выполните /rename снова.", - "rename.blocked.expected_name": - "⚠️ Введите новое название текстом или нажмите Отмена в сообщении переименования.", - "rename.blocked.command_not_allowed": - "⚠️ Эта команда недоступна, пока ожидается новое название сессии.", - "rename.button.cancel": "❌ Отмена", - "task.prompt.schedule": "⏰ Отправьте расписание задачи обычным языком.\n\nПримеры:\n- каждые 5 минут\n- каждый день в 17:00\n- завтра в 12:00", "task.schedule_empty": "⚠️ Расписание не может быть пустым.", @@ -525,8 +530,6 @@ export const ru: I18nDictionary = { "mcps.button.back": "⬅️ Назад", "mcps.auth_required": "Этот сервер требует авторизации и не может быть включен из бота.", - "cmd.description.rename": "Переименовать текущую сессию", - "legacy.models.fetch_error": "🔴 Не удалось получить список моделей. Проверьте статус сервера /status.", "legacy.models.empty": "📋 Нет доступных моделей. Настройте провайдеры через OpenCode.", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 8fa3b2ec..b66edf01 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -146,6 +146,24 @@ export const zh: I18nDictionary = { "sessions.preview.title": "最近消息:", "sessions.preview.you": "你:", "sessions.preview.agent": "代理:", + "sessions.button.select": "✅ 选择", + "sessions.button.rename": "✏️ 重命名", + "sessions.button.delete": "🗑 删除", + "sessions.button.close": "✖ 关闭", + "sessions.current_session": "已选择", + "sessions.rename.prompt": "📝 输入新标题:\n\n当前:{title}", + "sessions.rename.cancel": "❌ 取消", + "sessions.rename.empty": "⚠️ 标题不能为空。", + "sessions.rename.success": "✅ 会话已重命名为:{title}", + "sessions.rename.error": "🔴 重命名会话失败。", + "sessions.delete.confirm": + '⚠️ 删除会话\n\n"{title}"\n所有消息和历史记录将被永久删除。', + "sessions.delete.yes": "✅ 是,删除", + "sessions.delete.no": "❌ 否", + "sessions.delete.success": "✅ 会话已删除:{title}", + "sessions.delete.not_found": "🔴 会话未找到。可能已被删除。", + "sessions.delete.error": "🔴 删除会话失败。", + "sessions.deleted_external": "🗑 会话已被删除:{title}\n您已自动分离。", "attach.project_not_selected": "🏗 未选择项目。\n\n请先使用 /projects 选择一个项目。", "attach.session_not_selected": "💬 未选择会话。\n\n请先使用 /sessions 选择一个会话。", @@ -351,18 +369,6 @@ export const zh: I18nDictionary = { "runtime.wizard.tty_required": "交互式向导需要 TTY 终端。请在交互式 shell 中运行 `opencode-telegram config`。", - "rename.no_session": "⚠️ 没有活动会话。请先创建或选择一个会话。", - "rename.prompt": "📝 请输入会话的新标题:\n\n当前:{title}", - "rename.empty_title": "⚠️ 标题不能为空。", - "rename.success": "✅ 会话已重命名为:{title}", - "rename.error": "🔴 重命名会话失败。", - "rename.cancelled": "❌ 重命名已取消。", - "rename.inactive_callback": "重命名请求已失效", - "rename.inactive": "⚠️ 重命名请求未激活。请再次运行 /rename。", - "rename.blocked.expected_name": "⚠️ 请以文本输入新会话名称,或在重命名消息中点击取消。", - "rename.blocked.command_not_allowed": "⚠️ 重命名等待新名称期间不可用此命令。", - "rename.button.cancel": "❌ 取消", - "task.prompt.schedule": "⏰ 请用自然语言发送任务的时间安排。\n\n示例:\n- 每 5 分钟\n- 每天 17:00\n- 明天 12:00", "task.schedule_empty": "⚠️ 时间安排不能为空。", @@ -471,8 +477,6 @@ export const zh: I18nDictionary = { "mcps.button.back": "⬅️ Back", "mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.", - "cmd.description.rename": "重命名当前会话", - "legacy.models.fetch_error": "🔴 获取模型列表失败。请使用 /status 检查服务器状态。", "legacy.models.empty": "📋 没有可用模型。请在 OpenCode 中配置 providers。", "legacy.models.header": "📋 可用模型:\n\n", diff --git a/src/interaction/cleanup.ts b/src/interaction/cleanup.ts index e2328dab..7a629349 100644 --- a/src/interaction/cleanup.ts +++ b/src/interaction/cleanup.ts @@ -1,6 +1,5 @@ import { permissionManager } from "../permission/manager.js"; import { questionManager } from "../question/manager.js"; -import { renameManager } from "../rename/manager.js"; import { taskCreationManager } from "../scheduled-task/creation-manager.js"; import { interactionManager } from "./manager.js"; import { logger } from "../utils/logger.js"; @@ -8,27 +7,24 @@ import { logger } from "../utils/logger.js"; export function clearAllInteractionState(reason: string): void { const questionActive = questionManager.isActive(); const permissionActive = permissionManager.isActive(); - const renameActive = renameManager.isWaitingForName(); const taskCreationActive = taskCreationManager.isActive(); const interactionSnapshot = interactionManager.getSnapshot(); questionManager.clear(); permissionManager.clear(); - renameManager.clear(); taskCreationManager.clear(); interactionManager.clear(reason); const hasAnyActiveState = questionActive || permissionActive || - renameActive || taskCreationActive || interactionSnapshot !== null; const message = `[InteractionCleanup] Cleared state: reason=${reason}, ` + `questionActive=${questionActive}, permissionActive=${permissionActive}, ` + - `renameActive=${renameActive}, taskCreationActive=${taskCreationActive}, ` + + `taskCreationActive=${taskCreationActive}, ` + `interactionKind=${interactionSnapshot?.kind || "none"}`; if (hasAnyActiveState) { diff --git a/src/interaction/guard.ts b/src/interaction/guard.ts index 52832461..133c9c5d 100644 --- a/src/interaction/guard.ts +++ b/src/interaction/guard.ts @@ -113,18 +113,19 @@ function createBusyBlockDecision( }; } -function isAllowedRenameCancelCallback(ctx: Context, state: InteractionState): boolean { +function isAllowedTaskCallback(ctx: Context, state: InteractionState): boolean { return ( - state.kind === "rename" && - state.expectedInput === "text" && - ctx.callbackQuery?.data === "rename:cancel" + state.kind === "task" && + (ctx.callbackQuery?.data === "task:cancel" || ctx.callbackQuery?.data === "task:retry-schedule") ); } -function isAllowedTaskCallback(ctx: Context, state: InteractionState): boolean { +function isAllowedSessionRenameCancelCallback(ctx: Context, state: InteractionState): boolean { return ( - state.kind === "task" && - (ctx.callbackQuery?.data === "task:cancel" || ctx.callbackQuery?.data === "task:retry-schedule") + state.kind === "custom" && + state.expectedInput === "text" && + state.metadata?.action === "session_rename" && + ctx.callbackQuery?.data === "rename:cancel" ); } @@ -195,7 +196,7 @@ export function resolveInteractionGuardDecision(ctx: Context): GuardDecision { return createBlockDecision(inputType, state, "expected_text", command); } - if (inputType === "callback" && isAllowedRenameCancelCallback(ctx, state)) { + if (inputType === "callback" && isAllowedSessionRenameCancelCallback(ctx, state)) { return createAllowDecision(inputType, state, command); } diff --git a/src/interaction/types.ts b/src/interaction/types.ts index b5573ec1..ba0743b4 100644 --- a/src/interaction/types.ts +++ b/src/interaction/types.ts @@ -1,4 +1,4 @@ -export type InteractionKind = "inline" | "permission" | "question" | "rename" | "task" | "custom"; +export type InteractionKind = "inline" | "permission" | "question" | "task" | "custom"; export type ExpectedInput = "callback" | "text" | "command" | "mixed"; diff --git a/src/model/manager.ts b/src/model/manager.ts index 085f527d..1ed5885a 100644 --- a/src/model/manager.ts +++ b/src/model/manager.ts @@ -1,4 +1,9 @@ -import { getCurrentModel, setCurrentModel } from "../settings/manager.js"; +import { + getCurrentModel, + setCurrentModel, + getCurrentSession, + getCurrentProject, +} from "../settings/manager.js"; import { config } from "../config.js"; import { opencodeClient } from "../opencode/client.js"; import { logger } from "../utils/logger.js"; @@ -395,6 +400,75 @@ export function fetchCurrentModel(): ModelInfo { return getStoredModel(); } +/** + * Fetch current model from session's last message and sync to settings. + * When a user selects a historical session, the model should match + * what that session was using — parallel to fetchCurrentAgent() for agents. + * + * Logic: + * 1. If no active session/project → return stored model + * 2. Read last message from session → extract providerID + modelID + * 3. If stored model differs from session model AND user explicitly selected it → prefer stored + * 4. Otherwise sync from session history → selectModel() + * + * @returns The resolved model info (may be updated from session) + */ +export async function fetchCurrentModelFromSession(): Promise { + const storedModel = getStoredModel(); + const session = getCurrentSession(); + const project = getCurrentProject(); + + if (!session || !project) { + return storedModel; + } + + try { + const { data: messages, error } = await opencodeClient.session.messages({ + sessionID: session.id, + directory: project.worktree, + limit: 1, + }); + + if (error || !messages || messages.length === 0) { + logger.debug("[ModelManager] No messages found, using stored model"); + return storedModel; + } + + const msg = messages[0].info; + // UserMessage: model is nested { providerID, modelID } + // AssistantMessage: providerID and modelID are top-level + const providerID = msg.role === "user" ? msg.model.providerID : msg.providerID; + const modelID = msg.role === "user" ? msg.model.modelID : msg.modelID; + const sessionModel: ModelInfo = { + providerID, + modelID, + variant: storedModel.variant || "default", + }; + + if (!sessionModel.providerID || !sessionModel.modelID) { + logger.debug("[ModelManager] Session message has no model info, using stored model"); + return storedModel; + } + + const sessionModelKey = getModelKey(sessionModel.providerID, sessionModel.modelID); + const storedModelKey = getModelKey(storedModel.providerID, storedModel.modelID); + + if (storedModelKey === sessionModelKey) { + logger.debug(`[ModelManager] Session model matches stored: ${sessionModelKey}`); + return storedModel; + } + + logger.info( + `[ModelManager] Syncing model from session: ${sessionModelKey} (was ${storedModelKey})`, + ); + selectModel(sessionModel); + return sessionModel; + } catch (err) { + logger.error("[ModelManager] Error fetching model from session:", err); + return storedModel; + } +} + /** * Select model and persist to settings * @param modelInfo Model to select diff --git a/src/rename/manager.ts b/src/rename/manager.ts deleted file mode 100644 index 7453e5f3..00000000 --- a/src/rename/manager.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { logger } from "../utils/logger.js"; - -interface RenameState { - isWaiting: boolean; - sessionId: string | null; - sessionDirectory: string | null; - currentTitle: string | null; - messageId: number | null; -} - -class RenameManager { - private state: RenameState = { - isWaiting: false, - sessionId: null, - sessionDirectory: null, - currentTitle: null, - messageId: null, - }; - - startWaiting(sessionId: string, directory: string, currentTitle: string): void { - logger.info(`[RenameManager] Starting rename flow for session: ${sessionId}`); - this.state = { - isWaiting: true, - sessionId, - sessionDirectory: directory, - currentTitle, - messageId: null, - }; - } - - setMessageId(messageId: number): void { - this.state.messageId = messageId; - } - - getMessageId(): number | null { - return this.state.messageId; - } - - isActiveMessage(messageId: number | null): boolean { - return ( - this.state.isWaiting && this.state.messageId !== null && this.state.messageId === messageId - ); - } - - isWaitingForName(): boolean { - return this.state.isWaiting; - } - - getSessionInfo(): { sessionId: string; directory: string; currentTitle: string } | null { - if (!this.state.isWaiting || !this.state.sessionId) { - return null; - } - return { - sessionId: this.state.sessionId, - directory: this.state.sessionDirectory!, - currentTitle: this.state.currentTitle!, - }; - } - - clear(): void { - logger.debug("[RenameManager] Clearing rename state"); - this.state = { - isWaiting: false, - sessionId: null, - sessionDirectory: null, - currentTitle: null, - messageId: null, - }; - } -} - -export const renameManager = new RenameManager(); diff --git a/tests/bot/commands/abort.test.ts b/tests/bot/commands/abort.test.ts index 94f205fe..2ec13425 100644 --- a/tests/bot/commands/abort.test.ts +++ b/tests/bot/commands/abort.test.ts @@ -4,7 +4,6 @@ import { abortCommand, abortCurrentOperation } from "../../../src/bot/commands/a import { clearAllInteractionState } from "../../../src/interaction/cleanup.js"; import { questionManager } from "../../../src/question/manager.js"; import { permissionManager } from "../../../src/permission/manager.js"; -import { renameManager } from "../../../src/rename/manager.js"; import { interactionManager } from "../../../src/interaction/manager.js"; import { foregroundSessionState } from "../../../src/scheduled-task/foreground-state.js"; import type { Question } from "../../../src/question/types.js"; @@ -72,11 +71,10 @@ const TEST_PERMISSION: PermissionRequest = { function activateInteractionState(): void { questionManager.startQuestions([TEST_QUESTION], "req-abort"); permissionManager.startPermission(TEST_PERMISSION, 101); - renameManager.startWaiting("session-1", "D:/repo", "Old title"); interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { sessionId: "session-1" }, + kind: "permission", + expectedInput: "callback", + metadata: { permissionId: "perm-1" }, }); } @@ -118,7 +116,6 @@ describe("bot/commands/abort", () => { expect(replyMock).toHaveBeenCalledWith(t("stop.no_active_session")); expect(questionManager.isActive()).toBe(false); expect(permissionManager.isActive()).toBe(false); - expect(renameManager.isWaitingForName()).toBe(false); expect(interactionManager.getSnapshot()).toBeNull(); expect(mocked.abortMock).not.toHaveBeenCalled(); }); @@ -160,7 +157,6 @@ describe("bot/commands/abort", () => { expect(questionManager.isActive()).toBe(false); expect(permissionManager.isActive()).toBe(false); - expect(renameManager.isWaitingForName()).toBe(false); expect(interactionManager.getSnapshot()).toBeNull(); expectAbortStateReleased("abort_confirmed"); expect(shouldSuppressUserAbortSessionError("session-1", "Aborted")).toBe(true); @@ -234,7 +230,6 @@ describe("bot/commands/abort", () => { expect(questionManager.isActive()).toBe(false); expect(permissionManager.isActive()).toBe(false); - expect(renameManager.isWaitingForName()).toBe(false); expect(interactionManager.getSnapshot()).toBeNull(); expectAbortStateReleased("abort_confirmed"); }); diff --git a/tests/bot/commands/rename.test.ts b/tests/bot/commands/rename.test.ts deleted file mode 100644 index b33bde2b..00000000 --- a/tests/bot/commands/rename.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { Context } from "grammy"; -import { - renameCommand, - handleRenameCancel, - handleRenameTextAnswer, -} from "../../../src/bot/commands/rename.js"; -import { renameManager } from "../../../src/rename/manager.js"; -import { interactionManager } from "../../../src/interaction/manager.js"; -import { t } from "../../../src/i18n/index.js"; - -const mocked = vi.hoisted(() => ({ - currentSession: { - id: "session-1", - title: "Old title", - directory: "D:/repo", - } as { id: string; title: string; directory: string } | null, - updateSessionMock: vi.fn(), - setCurrentSessionMock: vi.fn(), - pinnedOnSessionChangeMock: vi.fn(), -})); - -vi.mock("../../../src/opencode/client.js", () => ({ - opencodeClient: { - session: { - update: mocked.updateSessionMock, - }, - }, -})); - -vi.mock("../../../src/session/manager.js", () => ({ - getCurrentSession: vi.fn(() => mocked.currentSession), - setCurrentSession: mocked.setCurrentSessionMock, -})); - -vi.mock("../../../src/pinned/manager.js", () => ({ - pinnedMessageManager: { - isInitialized: vi.fn(() => false), - onSessionChange: mocked.pinnedOnSessionChangeMock, - }, -})); - -function createRenameCommandContext(messageId: number): Context { - return { - reply: vi.fn().mockResolvedValue({ message_id: messageId }), - } as unknown as Context; -} - -function createRenameTextContext(text: string): Context { - return { - chat: { id: 101 }, - message: { text } as Context["message"], - api: { - deleteMessage: vi.fn().mockResolvedValue(true), - }, - reply: vi.fn().mockResolvedValue(undefined), - } as unknown as Context; -} - -function createRenameCallbackContext(messageId: number): Context { - return { - callbackQuery: { - data: "rename:cancel", - message: { - message_id: messageId, - }, - } as Context["callbackQuery"], - answerCallbackQuery: vi.fn().mockResolvedValue(undefined), - editMessageText: vi.fn().mockResolvedValue(undefined), - } as unknown as Context; -} - -describe("bot/commands/rename", () => { - beforeEach(() => { - renameManager.clear(); - interactionManager.clear("test_setup"); - - mocked.currentSession = { - id: "session-1", - title: "Old title", - directory: "D:/repo", - }; - mocked.updateSessionMock.mockReset(); - mocked.updateSessionMock.mockResolvedValue({ - data: { id: "session-1", title: "New title" }, - error: null, - }); - mocked.setCurrentSessionMock.mockReset(); - mocked.pinnedOnSessionChangeMock.mockReset(); - mocked.pinnedOnSessionChangeMock.mockResolvedValue(undefined); - }); - - it("starts rename flow and interaction state", async () => { - const ctx = createRenameCommandContext(555); - - await renameCommand(ctx as never); - - expect(renameManager.isWaitingForName()).toBe(true); - expect(renameManager.getMessageId()).toBe(555); - - const interactionState = interactionManager.getSnapshot(); - expect(interactionState?.kind).toBe("rename"); - expect(interactionState?.expectedInput).toBe("text"); - expect(interactionState?.metadata.sessionId).toBe("session-1"); - expect(interactionState?.metadata.messageId).toBe(555); - }); - - it("renames session on valid text and clears states", async () => { - renameManager.startWaiting("session-1", "D:/repo", "Old title"); - renameManager.setMessageId(555); - interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { sessionId: "session-1", messageId: 555 }, - }); - - const ctx = createRenameTextContext(" New title "); - const handled = await handleRenameTextAnswer(ctx); - - expect(handled).toBe(true); - expect(mocked.updateSessionMock).toHaveBeenCalledWith({ - sessionID: "session-1", - directory: "D:/repo", - title: "New title", - }); - expect(mocked.setCurrentSessionMock).toHaveBeenCalledWith({ - id: "session-1", - title: "New title", - directory: "D:/repo", - }); - expect(ctx.api.deleteMessage).toHaveBeenCalledWith(101, 555); - expect(ctx.reply).toHaveBeenCalledWith(t("rename.success", { title: "New title" })); - expect(renameManager.isWaitingForName()).toBe(false); - expect(interactionManager.getSnapshot()).toBeNull(); - }); - - it("keeps rename flow active on empty title", async () => { - renameManager.startWaiting("session-1", "D:/repo", "Old title"); - renameManager.setMessageId(555); - interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { sessionId: "session-1", messageId: 555 }, - }); - - const ctx = createRenameTextContext(" "); - const handled = await handleRenameTextAnswer(ctx); - - expect(handled).toBe(true); - expect(ctx.reply).toHaveBeenCalledWith(t("rename.empty_title")); - expect(mocked.updateSessionMock).not.toHaveBeenCalled(); - expect(renameManager.isWaitingForName()).toBe(true); - expect(interactionManager.getSnapshot()?.kind).toBe("rename"); - }); - - it("rejects stale rename cancel callback", async () => { - renameManager.startWaiting("session-1", "D:/repo", "Old title"); - renameManager.setMessageId(555); - interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { sessionId: "session-1", messageId: 555 }, - }); - - const ctx = createRenameCallbackContext(999); - const handled = await handleRenameCancel(ctx); - - expect(handled).toBe(true); - expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ - text: t("rename.inactive_callback"), - show_alert: true, - }); - expect(renameManager.isWaitingForName()).toBe(true); - expect(interactionManager.getSnapshot()?.kind).toBe("rename"); - }); - - it("cancels active rename and clears states", async () => { - renameManager.startWaiting("session-1", "D:/repo", "Old title"); - renameManager.setMessageId(555); - interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { sessionId: "session-1", messageId: 555 }, - }); - - const ctx = createRenameCallbackContext(555); - const handled = await handleRenameCancel(ctx); - - expect(handled).toBe(true); - expect(ctx.answerCallbackQuery).toHaveBeenCalled(); - expect(ctx.editMessageText).toHaveBeenCalledWith(t("rename.cancelled")); - expect(renameManager.isWaitingForName()).toBe(false); - expect(interactionManager.getSnapshot()).toBeNull(); - }); - - it("clears stale rename manager state when interaction is missing", async () => { - renameManager.startWaiting("session-1", "D:/repo", "Old title"); - renameManager.setMessageId(555); - - const ctx = createRenameTextContext("New title"); - const handled = await handleRenameTextAnswer(ctx); - - expect(handled).toBe(true); - expect(ctx.reply).toHaveBeenCalledWith(t("rename.inactive")); - expect(mocked.updateSessionMock).not.toHaveBeenCalled(); - expect(renameManager.isWaitingForName()).toBe(false); - }); -}); diff --git a/tests/bot/commands/sessions.test.ts b/tests/bot/commands/sessions.test.ts index 17515838..84320f87 100644 --- a/tests/bot/commands/sessions.test.ts +++ b/tests/bot/commands/sessions.test.ts @@ -3,6 +3,8 @@ import type { Bot, Context } from "grammy"; import { buildBackgroundSessionOpenKeyboard, handleBackgroundSessionOpen, + handleRenameCancelCallback, + handleRenameTextAnswer, handleSessionSelect, sessionsCommand, } from "../../../src/bot/commands/sessions.js"; @@ -17,41 +19,62 @@ const mocked = vi.hoisted(() => ({ worktree: "/repo", } as { id: string; worktree: string; name?: string } | null, sessionListMock: vi.fn(), + sessionDeleteMock: vi.fn(), sessionGetMock: vi.fn(), sessionMessagesMock: vi.fn(), + sessionUpdateMock: vi.fn(), setCurrentSessionMock: vi.fn(), + detachAttachedSessionMock: vi.fn(), + clearSessionMock: vi.fn(), + getCurrentSessionMock: vi.fn(() => null), clearSummaryMock: vi.fn(), clearInteractionMock: vi.fn(), keyboardInitializeMock: vi.fn(), keyboardGetKeyboardMock: vi.fn(() => ({ inline_keyboard: [] })), keyboardUpdateAgentMock: vi.fn(), + keyboardUpdateModelMock: vi.fn(), keyboardUpdateContextMock: vi.fn(), keyboardGetContextInfoMock: vi.fn(() => null), pinnedIsInitializedMock: vi.fn(() => false), pinnedInitializeMock: vi.fn(), + pinnedClearMock: vi.fn(), pinnedOnSessionChangeMock: vi.fn(), pinnedLoadContextFromHistoryMock: vi.fn(), + pinnedRefreshContextLimitMock: vi.fn(() => {}), + pinnedGetContextLimitMock: vi.fn(() => 100000), pinnedGetContextInfoMock: vi.fn(() => null), - resolveProjectAgentMock: vi.fn(async () => "build"), + fetchCurrentAgentMock: vi.fn(() => "build"), + fetchCurrentModelFromSessionMock: vi.fn(), + setCurrentAgentMock: vi.fn(), attachToSessionMock: vi.fn(), + foregroundBusy: false, + foregroundMarkIdleMock: vi.fn(), + assistantClearRunMock: vi.fn(), + clearPromptResponseModeMock: vi.fn(), ensureEventSubscriptionMock: vi.fn(), })); vi.mock("../../../src/opencode/client.js", () => ({ opencodeClient: { session: { + delete: mocked.sessionDeleteMock, list: mocked.sessionListMock, get: mocked.sessionGetMock, messages: mocked.sessionMessagesMock, + update: mocked.sessionUpdateMock, }, }, })); vi.mock("../../../src/settings/manager.js", () => ({ getCurrentProject: vi.fn(() => mocked.currentProject), + setCurrentAgent: mocked.setCurrentAgentMock, + clearCurrentAgent: mocked.setCurrentAgentMock, })); vi.mock("../../../src/session/manager.js", () => ({ + clearSession: mocked.clearSessionMock, + getCurrentSession: mocked.getCurrentSessionMock, setCurrentSession: mocked.setCurrentSessionMock, })); @@ -71,26 +94,58 @@ vi.mock("../../../src/keyboard/manager.js", () => ({ getKeyboard: mocked.keyboardGetKeyboardMock, getContextInfo: mocked.keyboardGetContextInfoMock, updateAgent: mocked.keyboardUpdateAgentMock, + updateModel: mocked.keyboardUpdateModelMock, updateContext: mocked.keyboardUpdateContextMock, }, })); vi.mock("../../../src/agent/manager.js", () => ({ - resolveProjectAgent: mocked.resolveProjectAgentMock, + fetchCurrentAgent: mocked.fetchCurrentAgentMock, +})); + +vi.mock("../../../src/model/manager.js", () => ({ + fetchCurrentModelFromSession: mocked.fetchCurrentModelFromSessionMock, })); vi.mock("../../../src/pinned/manager.js", () => ({ pinnedMessageManager: { isInitialized: mocked.pinnedIsInitializedMock, initialize: mocked.pinnedInitializeMock, + clear: mocked.pinnedClearMock, onSessionChange: mocked.pinnedOnSessionChangeMock, loadContextFromHistory: mocked.pinnedLoadContextFromHistoryMock, + refreshContextLimit: mocked.pinnedRefreshContextLimitMock, + getContextLimit: mocked.pinnedGetContextLimitMock, getContextInfo: mocked.pinnedGetContextInfoMock, }, })); vi.mock("../../../src/attach/service.js", () => ({ attachToSession: mocked.attachToSessionMock, + detachAttachedSession: mocked.detachAttachedSessionMock, +})); + +vi.mock("../../../src/scheduled-task/foreground-state.js", () => ({ + foregroundSessionState: { + __resetForTests: vi.fn(() => { + mocked.foregroundBusy = false; + }), + isBusy: vi.fn(() => mocked.foregroundBusy), + markBusy: vi.fn(() => { + mocked.foregroundBusy = true; + }), + markIdle: mocked.foregroundMarkIdleMock, + }, +})); + +vi.mock("../../../src/bot/assistant-run-state.js", () => ({ + assistantRunState: { + clearRun: mocked.assistantClearRunMock, + }, +})); + +vi.mock("../../../src/bot/handlers/prompt.js", () => ({ + clearPromptResponseMode: mocked.clearPromptResponseModeMock, })); vi.mock("../../../src/utils/safe-background-task.js", () => ({ @@ -209,9 +264,17 @@ describe("bot/commands/sessions", () => { }; mocked.sessionListMock.mockReset(); + mocked.sessionDeleteMock.mockReset(); + mocked.sessionDeleteMock.mockResolvedValue({ data: true, error: null }); mocked.sessionGetMock.mockReset(); mocked.sessionMessagesMock.mockReset(); + mocked.sessionMessagesMock.mockResolvedValue({ data: [], error: null }); + mocked.sessionUpdateMock.mockReset(); mocked.setCurrentSessionMock.mockReset(); + mocked.detachAttachedSessionMock.mockReset(); + mocked.clearSessionMock.mockReset(); + mocked.getCurrentSessionMock.mockReset(); + mocked.getCurrentSessionMock.mockReturnValue(null); mocked.clearSummaryMock.mockReset(); mocked.clearInteractionMock.mockReset(); mocked.keyboardInitializeMock.mockReset(); @@ -220,18 +283,31 @@ describe("bot/commands/sessions", () => { mocked.keyboardGetContextInfoMock.mockReset(); mocked.keyboardGetContextInfoMock.mockReturnValue(null); mocked.keyboardUpdateAgentMock.mockReset(); + mocked.keyboardUpdateModelMock.mockReset(); mocked.keyboardUpdateContextMock.mockReset(); mocked.pinnedIsInitializedMock.mockReset(); mocked.pinnedIsInitializedMock.mockReturnValue(false); mocked.pinnedInitializeMock.mockReset(); + mocked.pinnedClearMock.mockReset(); + mocked.pinnedClearMock.mockResolvedValue(undefined); mocked.pinnedOnSessionChangeMock.mockReset(); mocked.pinnedOnSessionChangeMock.mockResolvedValue(undefined); mocked.pinnedLoadContextFromHistoryMock.mockReset(); mocked.pinnedLoadContextFromHistoryMock.mockResolvedValue(undefined); + mocked.pinnedRefreshContextLimitMock.mockReset(); + mocked.pinnedRefreshContextLimitMock.mockResolvedValue(undefined); + mocked.pinnedGetContextLimitMock.mockReset(); + mocked.pinnedGetContextLimitMock.mockReturnValue(100000); mocked.pinnedGetContextInfoMock.mockReset(); mocked.pinnedGetContextInfoMock.mockReturnValue(null); - mocked.resolveProjectAgentMock.mockReset(); - mocked.resolveProjectAgentMock.mockResolvedValue("build"); + mocked.fetchCurrentAgentMock.mockReset(); + mocked.fetchCurrentAgentMock.mockReturnValue("code"); + mocked.fetchCurrentModelFromSessionMock.mockReset(); + mocked.fetchCurrentModelFromSessionMock.mockResolvedValue({ + providerID: "anthropic", + modelID: "claude-3.5-sonnet", + variant: "default", + }); mocked.attachToSessionMock.mockReset(); mocked.attachToSessionMock.mockResolvedValue({ busy: false, @@ -239,6 +315,9 @@ describe("bot/commands/sessions", () => { restoredQuestion: false, restoredPermissions: 0, }); + mocked.foregroundMarkIdleMock.mockReset(); + mocked.assistantClearRunMock.mockReset(); + mocked.clearPromptResponseModeMock.mockReset(); mocked.ensureEventSubscriptionMock.mockReset(); safeBackgroundTaskMock.mockReset(); }); @@ -257,8 +336,8 @@ describe("bot/commands/sessions", () => { }); const keyboardRows = getKeyboardButtons(ctx); - expect(keyboardRows[0]?.[0]?.callback_data).toBe("session:session-1"); - expect(keyboardRows[9]?.[0]?.callback_data).toBe("session:session-10"); + expect(keyboardRows[0]?.[0]?.callback_data).toBe("session:preview:session-1"); + expect(keyboardRows[9]?.[0]?.callback_data).toBe("session:preview:session-10"); expect(keyboardRows[10]?.[0]?.callback_data).toBe("session:page:1"); expect(keyboardRows[11]?.[0]?.callback_data).toBe("inline:cancel:session"); }); @@ -304,8 +383,8 @@ describe("bot/commands/sessions", () => { expect(text).toBe(t("sessions.select_page", { page: 2 })); const inlineRows = options.reply_markup.inline_keyboard; - expect(inlineRows[0]?.[0]?.callback_data).toBe("session:session-11"); - expect(inlineRows[1]?.[0]?.callback_data).toBe("session:session-12"); + expect(inlineRows[0]?.[0]?.callback_data).toBe("session:preview:session-11"); + expect(inlineRows[1]?.[0]?.callback_data).toBe("session:preview:session-12"); expect(inlineRows[2]?.[0]?.callback_data).toBe("session:page:0"); expect(inlineRows[3]?.[0]?.callback_data).toBe("inline:cancel:session"); }); @@ -374,21 +453,22 @@ describe("bot/commands/sessions", () => { }, }); - const ctx = createCallbackContext("session:session-1", 456); + const ctx = createCallbackContext("session:preview:session-1", 456); const handled = await handleSessionSelect(ctx, createDeps()); expect(handled).toBe(true); - expect(mocked.clearInteractionMock).toHaveBeenCalledWith("session_select_error"); - expect(ctx.answerCallbackQuery).toHaveBeenCalled(); - expect(ctx.reply).toHaveBeenCalledWith(t("sessions.select_error")); + expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ + text: t("sessions.select_error"), + show_alert: true, + }); }); - it("resolves the project agent before sending the keyboard for an existing session", async () => { + it("syncs context, agent, and model before sending the keyboard for an existing session", async () => { mocked.sessionGetMock.mockResolvedValueOnce({ data: createSession(0), error: null, }); - mocked.resolveProjectAgentMock.mockResolvedValueOnce("plan"); + mocked.fetchCurrentAgentMock.mockReturnValueOnce("plan"); interactionManager.start({ kind: "inline", @@ -399,12 +479,17 @@ describe("bot/commands/sessions", () => { }, }); - const ctx = createCallbackContext("session:session-1", 456); + const ctx = createCallbackContext("session:select:session-1", 456); const handled = await handleSessionSelect(ctx, createDeps()); expect(handled).toBe(true); - expect(mocked.resolveProjectAgentMock).toHaveBeenCalledOnce(); + expect(mocked.pinnedRefreshContextLimitMock).toHaveBeenCalled(); + expect(mocked.fetchCurrentAgentMock).toHaveBeenCalled(); + expect(mocked.fetchCurrentModelFromSessionMock).toHaveBeenCalled(); expect(mocked.keyboardUpdateAgentMock).toHaveBeenCalledWith("plan"); + expect(mocked.keyboardUpdateModelMock).toHaveBeenCalledWith( + expect.objectContaining({ providerID: "anthropic", modelID: "claude-3.5-sonnet" }), + ); expect(mocked.attachToSessionMock).toHaveBeenCalledWith({ bot: expect.any(Object), chatId: 111, @@ -415,18 +500,17 @@ describe("bot/commands/sessions", () => { }, ensureEventSubscription: mocked.ensureEventSubscriptionMock, }); - expect((ctx.api.sendMessage as ReturnType).mock.calls[1]).toEqual([ + const sendMessageCalls = (ctx.api.sendMessage as ReturnType).mock.calls; + const selectedCall = sendMessageCalls.find( + (call: unknown[]) => typeof call[1] === "string" && (call[1] as string).includes("Session 1"), + ); + expect(selectedCall).toEqual([ 111, t("sessions.selected", { title: "Session 1" }), expect.objectContaining({ reply_markup: { inline_keyboard: [] }, }), ]); - expect(safeBackgroundTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - taskName: "sessions.sendPreview", - }), - ); }); it("blocks session selection callback while foreground session is busy", async () => { @@ -441,7 +525,7 @@ describe("bot/commands/sessions", () => { }, }); - const ctx = createCallbackContext("session:session-1", 456); + const ctx = createCallbackContext("session:preview:session-1", 456); const handled = await handleSessionSelect(ctx, createDeps()); expect(handled).toBe(true); @@ -659,4 +743,400 @@ describe("bot/commands/sessions", () => { expect(mocked.sessionGetMock).not.toHaveBeenCalled(); expect(ctx.answerCallbackQuery).not.toHaveBeenCalled(); }); + + describe("session preview panel", () => { + it("shows preview with action buttons when session:preview:{id} is clicked", async () => { + mocked.sessionGetMock.mockResolvedValueOnce({ data: createSession(0), error: null }); + mocked.sessionMessagesMock.mockResolvedValueOnce({ + data: [ + createSessionMessage("user", "Please inspect the bug", 100), + createSessionMessage("assistant", "The issue is fixed", 200), + ], + error: null, + }); + interactionManager.start({ + kind: "inline", + expectedInput: "callback", + metadata: { + menuKind: "session", + messageId: 456, + }, + }); + + const ctx = createCallbackContext("session:preview:session-1", 456); + const handled = await handleSessionSelect(ctx, createDeps()); + + expect(handled).toBe(true); + expect(mocked.sessionGetMock).toHaveBeenCalledWith({ + sessionID: "session-1", + directory: "/repo", + }); + expect(mocked.sessionMessagesMock).toHaveBeenCalledWith({ + sessionID: "session-1", + directory: "/repo", + limit: 6, + }); + expect(ctx.editMessageText).toHaveBeenCalledTimes(1); + + const [text, options] = (ctx.editMessageText as ReturnType).mock.calls[0] as [ + string, + { reply_markup: { inline_keyboard: Array> } }, + ]; + + expect(text).toContain(t("sessions.preview.title")); + expect(text).toContain(`${t("sessions.preview.you")} Please inspect the bug`); + expect(text).toContain(`${t("sessions.preview.agent")} The issue is fixed`); + expect(options.reply_markup.inline_keyboard).toEqual([ + [ + { text: t("sessions.button.select"), callback_data: "session:select:session-1" }, + { text: t("sessions.button.rename"), callback_data: "session:rename:session-1" }, + ], + [ + { text: t("sessions.button.delete"), callback_data: "session:delete:session-1" }, + { text: t("sessions.button.close"), callback_data: "inline:cancel:session" }, + ], + ]); + expect(ctx.answerCallbackQuery).toHaveBeenCalledWith(); + }); + + it("answers callback with error when preview fetch fails", async () => { + mocked.sessionGetMock.mockResolvedValueOnce({ + data: null, + error: new Error("session get failed"), + }); + interactionManager.start({ + kind: "inline", + expectedInput: "callback", + metadata: { + menuKind: "session", + messageId: 456, + }, + }); + + const ctx = createCallbackContext("session:preview:session-1", 456); + const handled = await handleSessionSelect(ctx, createDeps()); + + expect(handled).toBe(true); + expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ + text: t("sessions.select_error"), + show_alert: true, + }); + expect(ctx.editMessageText).not.toHaveBeenCalled(); + }); + }); + + describe("session select from preview panel", () => { + it("selects session when session:select:{id} is clicked", async () => { + mocked.sessionGetMock.mockResolvedValueOnce({ data: createSession(0), error: null }); + mocked.fetchCurrentAgentMock.mockReturnValueOnce("plan"); + interactionManager.start({ + kind: "inline", + expectedInput: "callback", + metadata: { + menuKind: "session", + messageId: 456, + }, + }); + + const ctx = createCallbackContext("session:select:session-1", 456); + const handled = await handleSessionSelect(ctx, createDeps()); + + expect(handled).toBe(true); + expect(mocked.setCurrentSessionMock).toHaveBeenCalledWith({ + id: "session-1", + title: "Session 1", + directory: "/repo", + }); + expect(mocked.attachToSessionMock).toHaveBeenCalledWith({ + bot: expect.any(Object), + chatId: 111, + session: { + id: "session-1", + title: "Session 1", + directory: "/repo", + }, + ensureEventSubscription: mocked.ensureEventSubscriptionMock, + }); + expect(mocked.pinnedRefreshContextLimitMock).toHaveBeenCalled(); + expect(mocked.fetchCurrentAgentMock).toHaveBeenCalled(); + expect(mocked.fetchCurrentModelFromSessionMock).toHaveBeenCalled(); + expect(mocked.keyboardUpdateAgentMock).toHaveBeenCalledWith("plan"); + expect(mocked.keyboardUpdateModelMock).toHaveBeenCalledWith( + expect.objectContaining({ providerID: "anthropic", modelID: "claude-3.5-sonnet" }), + ); + expect(ctx.editMessageReplyMarkup).toHaveBeenCalledOnce(); + }); + }); + + describe("session rename flow", () => { + it("enters rename mode when session:rename:{id} is clicked", async () => { + mocked.sessionGetMock.mockResolvedValueOnce({ data: createSession(0), error: null }); + interactionManager.start({ + kind: "inline", + expectedInput: "callback", + metadata: { + menuKind: "session", + messageId: 456, + }, + }); + + const ctx = createCallbackContext("session:rename:session-1", 456); + const handled = await handleSessionSelect(ctx, createDeps()); + + expect(handled).toBe(true); + expect(ctx.editMessageText).toHaveBeenCalledWith(t("sessions.rename.prompt", { title: "Session 1" }), { + reply_markup: expect.objectContaining({ + inline_keyboard: [[{ text: t("sessions.rename.cancel"), callback_data: "rename:cancel" }]], + }), + }); + + const snapshot = interactionManager.getSnapshot(); + expect(snapshot?.kind).toBe("custom"); + expect(snapshot?.metadata).toEqual({ + action: "session_rename", + sessionId: "session-1", + directory: "/repo", + currentTitle: "Session 1", + }); + }); + + it("processes rename text input successfully", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "text", + metadata: { + action: "session_rename", + sessionId: "session-1", + directory: "/repo", + currentTitle: "Session 1", + }, + }); + mocked.sessionUpdateMock.mockResolvedValueOnce({ + data: { ...createSession(0), title: "New Title" }, + error: null, + }); + + const ctx = { + ...createCommandContext(), + message: { text: "New Title" }, + } as unknown as Context; + const handled = await handleRenameTextAnswer(ctx); + + expect(handled).toBe(true); + expect(mocked.sessionUpdateMock).toHaveBeenCalledWith({ + sessionID: "session-1", + directory: "/repo", + title: "New Title", + }); + expect(ctx.reply).toHaveBeenCalledWith(t("sessions.rename.success", { title: "New Title" })); + expect(interactionManager.getSnapshot()).toBeNull(); + }); + + it("rejects empty title during rename", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "text", + metadata: { + action: "session_rename", + sessionId: "session-1", + directory: "/repo", + currentTitle: "Session 1", + }, + }); + + const ctx = { + ...createCommandContext(), + message: { text: " " }, + } as unknown as Context; + const handled = await handleRenameTextAnswer(ctx); + + expect(handled).toBe(true); + expect(ctx.reply).toHaveBeenCalledWith(t("sessions.rename.empty")); + expect(mocked.sessionUpdateMock).not.toHaveBeenCalled(); + expect(interactionManager.getSnapshot()).toEqual( + expect.objectContaining({ + kind: "custom", + metadata: expect.objectContaining({ action: "session_rename" }), + }), + ); + }); + + it("cancels rename when cancel button is clicked", async () => { + interactionManager.start({ + kind: "custom", + expectedInput: "text", + metadata: { + action: "session_rename", + sessionId: "session-1", + directory: "/repo", + currentTitle: "Session 1", + }, + }); + mocked.sessionGetMock.mockResolvedValueOnce({ data: createSession(0), error: null }); + mocked.sessionMessagesMock.mockResolvedValueOnce({ data: [], error: null }); + + const ctx = createCallbackContext("rename:cancel", 456); + const handled = await handleRenameCancelCallback(ctx); + + expect(handled).toBe(true); + expect(interactionManager.getSnapshot()).toBeNull(); + expect(ctx.answerCallbackQuery).toHaveBeenCalledWith(); + expect(ctx.editMessageText).toHaveBeenCalledWith(t("sessions.preview.empty"), { + reply_markup: expect.objectContaining({ + inline_keyboard: expect.any(Array), + }), + }); + }); + + it("ignores text input when not in rename interaction", async () => { + const ctx = { + ...createCommandContext(), + message: { text: "New Title" }, + } as unknown as Context; + const handled = await handleRenameTextAnswer(ctx); + + expect(handled).toBe(false); + expect(mocked.sessionUpdateMock).not.toHaveBeenCalled(); + }); + }); + + describe("session delete flow", () => { + it("shows delete confirmation when session:delete:{id} is clicked", async () => { + mocked.sessionGetMock.mockResolvedValueOnce({ data: createSession(0), error: null }); + interactionManager.start({ + kind: "inline", + expectedInput: "callback", + metadata: { + menuKind: "session", + messageId: 456, + }, + }); + + const ctx = createCallbackContext("session:delete:session-1", 456); + const handled = await handleSessionSelect(ctx, createDeps()); + + expect(handled).toBe(true); + expect(ctx.editMessageText).toHaveBeenCalledWith(t("sessions.delete.confirm", { title: "Session 1" }), { + reply_markup: expect.objectContaining({ + inline_keyboard: [ + [ + { text: t("sessions.delete.yes"), callback_data: "session:delete:confirm:session-1" }, + { text: t("sessions.delete.no"), callback_data: "session:delete:cancel:session-1" }, + ], + ], + }), + }); + expect(ctx.answerCallbackQuery).toHaveBeenCalledWith(); + }); + + it("deletes session when session:delete:confirm:{id} is clicked", async () => { + mocked.sessionGetMock.mockResolvedValueOnce({ data: createSession(0), error: null }); + mocked.sessionDeleteMock.mockResolvedValueOnce({ data: true, error: null }); + interactionManager.start({ + kind: "inline", + expectedInput: "callback", + metadata: { + menuKind: "session", + messageId: 456, + }, + }); + + const ctx = createCallbackContext("session:delete:confirm:session-1", 456); + const handled = await handleSessionSelect(ctx, createDeps()); + + expect(handled).toBe(true); + expect(mocked.sessionDeleteMock).toHaveBeenCalledWith({ + sessionID: "session-1", + directory: "/repo", + }); + expect(mocked.clearInteractionMock).toHaveBeenCalledWith("session_deleted_other"); + expect(ctx.editMessageText).toHaveBeenCalledWith( + t("sessions.delete.success", { title: "Session 1" }), + ); + expect(ctx.answerCallbackQuery).toHaveBeenCalledWith(); + }); + + it("cleans up local state when deleting current active session", async () => { + mocked.sessionGetMock.mockResolvedValueOnce({ data: createSession(0), error: null }); + mocked.sessionDeleteMock.mockResolvedValueOnce({ data: true, error: null }); + mocked.getCurrentSessionMock.mockReturnValueOnce({ + id: "session-1", + title: "Session 1", + directory: "/repo", + }); + interactionManager.start({ + kind: "inline", + expectedInput: "callback", + metadata: { + menuKind: "session", + messageId: 456, + }, + }); + + const ctx = createCallbackContext("session:delete:confirm:session-1", 456); + const handled = await handleSessionSelect(ctx, createDeps()); + + expect(handled).toBe(true); + expect(mocked.detachAttachedSessionMock).toHaveBeenCalledWith("session_deleted"); + expect(mocked.clearPromptResponseModeMock).toHaveBeenCalledWith("session-1"); + expect(mocked.foregroundMarkIdleMock).toHaveBeenCalledWith("session-1"); + expect(mocked.assistantClearRunMock).toHaveBeenCalledWith("session-1", "session_deleted"); + expect(mocked.clearInteractionMock).toHaveBeenCalledWith("session_deleted"); + expect(mocked.clearSessionMock).toHaveBeenCalledOnce(); + expect(mocked.keyboardInitializeMock).toHaveBeenCalledWith(ctx.api, 111); + expect(mocked.pinnedRefreshContextLimitMock).toHaveBeenCalledOnce(); + expect(mocked.keyboardUpdateContextMock).toHaveBeenCalledWith(0, 100000); + }); + + it("cancels delete when no button is clicked", async () => { + mocked.sessionGetMock.mockResolvedValueOnce({ data: createSession(0), error: null }); + mocked.sessionMessagesMock.mockResolvedValueOnce({ data: [], error: null }); + interactionManager.start({ + kind: "inline", + expectedInput: "callback", + metadata: { + menuKind: "session", + messageId: 456, + }, + }); + + const ctx = createCallbackContext("session:delete:cancel:session-1", 456); + const handled = await handleSessionSelect(ctx, createDeps()); + + expect(handled).toBe(true); + expect(mocked.sessionDeleteMock).not.toHaveBeenCalled(); + expect(ctx.editMessageText).toHaveBeenCalledWith(t("sessions.preview.empty"), { + reply_markup: expect.objectContaining({ + inline_keyboard: expect.any(Array), + }), + }); + expect(ctx.answerCallbackQuery).toHaveBeenCalledWith(); + }); + + it("handles delete failure gracefully", async () => { + mocked.sessionGetMock.mockResolvedValueOnce({ data: createSession(0), error: null }); + mocked.sessionDeleteMock.mockResolvedValueOnce({ + data: null, + error: new Error("delete failed"), + }); + interactionManager.start({ + kind: "inline", + expectedInput: "callback", + metadata: { + menuKind: "session", + messageId: 456, + }, + }); + + const ctx = createCallbackContext("session:delete:confirm:session-1", 456); + const handled = await handleSessionSelect(ctx, createDeps()); + + expect(handled).toBe(true); + expect(ctx.editMessageText).toHaveBeenCalledWith(t("sessions.delete.error")); + expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ + text: t("sessions.delete.error"), + show_alert: true, + }); + }); + }); }); diff --git a/tests/bot/middleware/interaction-guard.test.ts b/tests/bot/middleware/interaction-guard.test.ts index d2509149..647d26fe 100644 --- a/tests/bot/middleware/interaction-guard.test.ts +++ b/tests/bot/middleware/interaction-guard.test.ts @@ -74,7 +74,7 @@ describe("interactionGuardMiddleware", () => { it("blocks callback and answers callback query when text is expected", async () => { interactionManager.start({ - kind: "rename", + kind: "task", expectedInput: "text", }); @@ -85,7 +85,7 @@ describe("interactionGuardMiddleware", () => { expect(next).not.toHaveBeenCalled(); expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ - text: t("rename.blocked.expected_name"), + text: t("task.blocked.expected_input"), }); expect(ctx.reply).not.toHaveBeenCalled(); }); @@ -169,37 +169,6 @@ describe("interactionGuardMiddleware", () => { expect(ctx.reply).toHaveBeenCalledWith(t("permission.blocked.command_not_allowed")); }); - it("shows rename-specific message for disallowed command", async () => { - interactionManager.start({ - kind: "rename", - expectedInput: "text", - allowedCommands: ["/status"], - }); - - const ctx = createTextContext("/new"); - const next: NextFunction = vi.fn().mockResolvedValue(undefined); - - await interactionGuardMiddleware(ctx, next); - - expect(next).not.toHaveBeenCalled(); - expect(ctx.reply).toHaveBeenCalledWith(t("rename.blocked.command_not_allowed")); - }); - - it("blocks voice input while rename interaction expects text", async () => { - interactionManager.start({ - kind: "rename", - expectedInput: "text", - }); - - const ctx = createVoiceContext(); - const next: NextFunction = vi.fn().mockResolvedValue(undefined); - - await interactionGuardMiddleware(ctx, next); - - expect(next).not.toHaveBeenCalled(); - expect(ctx.reply).toHaveBeenCalledWith(t("rename.blocked.expected_name")); - }); - it("shows question-specific message for blocked text", async () => { interactionManager.start({ kind: "question", diff --git a/tests/helpers/reset-singleton-state.ts b/tests/helpers/reset-singleton-state.ts index da0dea1c..1d0ed5fd 100644 --- a/tests/helpers/reset-singleton-state.ts +++ b/tests/helpers/reset-singleton-state.ts @@ -48,7 +48,6 @@ export async function resetSingletonState(): Promise { const [ { questionManager }, { permissionManager }, - { renameManager }, { interactionManager }, { summaryAggregator }, { keyboardManager }, @@ -59,7 +58,6 @@ export async function resetSingletonState(): Promise { ] = await Promise.all([ import("../../src/question/manager.js"), import("../../src/permission/manager.js"), - import("../../src/rename/manager.js"), import("../../src/interaction/manager.js"), import("../../src/summary/aggregator.js"), import("../../src/keyboard/manager.js"), @@ -72,7 +70,6 @@ export async function resetSingletonState(): Promise { stopEventListening(); questionManager.clear(); permissionManager.clear(); - renameManager.clear(); interactionManager.clear("test_reset"); summaryAggregator.clear(); diff --git a/tests/interaction/cleanup.test.ts b/tests/interaction/cleanup.test.ts index 26c8f957..ec409017 100644 --- a/tests/interaction/cleanup.test.ts +++ b/tests/interaction/cleanup.test.ts @@ -3,7 +3,6 @@ import { clearAllInteractionState } from "../../src/interaction/cleanup.js"; import { interactionManager } from "../../src/interaction/manager.js"; import { questionManager } from "../../src/question/manager.js"; import { permissionManager } from "../../src/permission/manager.js"; -import { renameManager } from "../../src/rename/manager.js"; import type { Question } from "../../src/question/types.js"; import type { PermissionRequest } from "../../src/permission/types.js"; @@ -33,18 +32,16 @@ describe("interaction/cleanup", () => { it("clears all interaction-related managers", () => { questionManager.startQuestions([TEST_QUESTION], "req-1"); permissionManager.startPermission(TEST_PERMISSION, 101); - renameManager.startWaiting("session-1", "D:/repo", "Old title"); interactionManager.start({ - kind: "rename", - expectedInput: "text", - metadata: { sessionId: "session-1" }, + kind: "permission", + expectedInput: "callback", + metadata: { permissionId: "perm-1" }, }); clearAllInteractionState("test_cleanup"); expect(questionManager.isActive()).toBe(false); expect(permissionManager.isActive()).toBe(false); - expect(renameManager.isWaitingForName()).toBe(false); expect(interactionManager.getSnapshot()).toBeNull(); }); diff --git a/tests/interaction/guard.test.ts b/tests/interaction/guard.test.ts index 5b85a8b7..e844bce1 100644 --- a/tests/interaction/guard.test.ts +++ b/tests/interaction/guard.test.ts @@ -169,7 +169,7 @@ describe("interaction guard", () => { it("blocks voice input when text input is expected", () => { interactionManager.start({ - kind: "rename", + kind: "task", expectedInput: "text", }); @@ -233,24 +233,9 @@ describe("interaction guard", () => { expect(decision.state?.kind).toBe("question"); }); - it("allows rename cancel callback when rename expects text", () => { + it("blocks non-cancel callback while task expects text", () => { interactionManager.start({ - kind: "rename", - expectedInput: "text", - }); - - const decision = resolveInteractionGuardDecision( - createContext({ callbackData: "rename:cancel" }), - ); - - expect(decision.allow).toBe(true); - expect(decision.inputType).toBe("callback"); - expect(decision.state?.kind).toBe("rename"); - }); - - it("blocks non-rename callback while rename expects text", () => { - interactionManager.start({ - kind: "rename", + kind: "task", expectedInput: "text", }); @@ -260,21 +245,7 @@ describe("interaction guard", () => { expect(decision.allow).toBe(false); expect(decision.reason).toBe("expected_text"); - expect(decision.state?.kind).toBe("rename"); - }); - - it("blocks photo input when text input is expected (rename)", () => { - interactionManager.start({ - kind: "rename", - expectedInput: "text", - }); - - const decision = resolveInteractionGuardDecision(createContext({ photo: true })); - - expect(decision.allow).toBe(false); - expect(decision.reason).toBe("expected_text"); - expect(decision.inputType).toBe("other"); - expect(decision.state?.kind).toBe("rename"); + expect(decision.state?.kind).toBe("task"); }); it("blocks photo input when mixed input is expected (question)", () => { @@ -375,15 +346,15 @@ describe("interaction guard", () => { expect(textDecision.busy).toBe(true); }); - it("does not allow rename callback to bypass busy state", () => { + it("does not allow task callback to bypass busy state", () => { foregroundSessionState.markBusy("session-1", "D:\\Projects\\Repo"); interactionManager.start({ - kind: "rename", + kind: "task", expectedInput: "text", }); const decision = resolveInteractionGuardDecision( - createContext({ callbackData: "rename:cancel" }), + createContext({ callbackData: "task:cancel" }), ); expect(decision.allow).toBe(false); diff --git a/tests/interaction/manager.test.ts b/tests/interaction/manager.test.ts index 7cfa4915..49149216 100644 --- a/tests/interaction/manager.test.ts +++ b/tests/interaction/manager.test.ts @@ -37,7 +37,7 @@ describe("interactionManager", () => { it("transitions active interaction", () => { interactionManager.start({ - kind: "rename", + kind: "task", expectedInput: "text", metadata: { step: 1 }, }); diff --git a/tests/model/manager.test.ts b/tests/model/manager.test.ts index 4e3b1993..cba9209f 100644 --- a/tests/model/manager.test.ts +++ b/tests/model/manager.test.ts @@ -6,10 +6,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { configMock, providersMock, + sessionMessagesMock, getCurrentModelMock, setCurrentModelMock, + getCurrentProjectMock, + getCurrentSessionMock, setCurrentModelState, getCurrentModelState, + setCurrentProjectState, + setCurrentSessionState, resetCurrentModelState, loggerInfoMock, loggerWarnMock, @@ -17,8 +22,12 @@ const { loggerDebugMock, } = vi.hoisted(() => { let currentModel: { providerID: string; modelID: string; variant?: string } | undefined; + let currentProject: { name: string; path: string; worktree: string } | undefined; + let currentSession: { id: string; title: string; directory: string } | null | undefined; const getCurrentModelMock = vi.fn(() => currentModel); + const getCurrentProjectMock = vi.fn(() => currentProject); + const getCurrentSessionMock = vi.fn(() => currentSession); const setCurrentModelMock = vi.fn( (modelInfo: { providerID: string; modelID: string; variant?: string }) => { currentModel = modelInfo; @@ -35,8 +44,11 @@ const { }, }, providersMock: vi.fn(), + sessionMessagesMock: vi.fn(), getCurrentModelMock, setCurrentModelMock, + getCurrentProjectMock, + getCurrentSessionMock, setCurrentModelState: (modelInfo?: { providerID: string; modelID: string; @@ -45,10 +57,20 @@ const { currentModel = modelInfo; }, getCurrentModelState: () => currentModel, + setCurrentProjectState: (project?: { name: string; path: string; worktree: string }) => { + currentProject = project; + }, + setCurrentSessionState: (session?: { id: string; title: string; directory: string } | null) => { + currentSession = session; + }, resetCurrentModelState: () => { currentModel = undefined; + currentProject = undefined; + currentSession = undefined; getCurrentModelMock.mockClear(); setCurrentModelMock.mockClear(); + getCurrentProjectMock.mockClear(); + getCurrentSessionMock.mockClear(); }, loggerInfoMock: vi.fn(), loggerWarnMock: vi.fn(), @@ -66,11 +88,16 @@ vi.mock("../../src/opencode/client.js", () => ({ config: { providers: providersMock, }, + session: { + messages: sessionMessagesMock, + }, }, })); vi.mock("../../src/settings/manager.js", () => ({ getCurrentModel: getCurrentModelMock, + getCurrentProject: getCurrentProjectMock, + getCurrentSession: getCurrentSessionMock, setCurrentModel: setCurrentModelMock, })); @@ -85,6 +112,7 @@ vi.mock("../../src/utils/logger.js", () => ({ import { __resetModelCatalogCacheForTests, + fetchCurrentModelFromSession, getFavoriteModels, getModelSelectionLists, reconcileStoredModelSelection, @@ -102,6 +130,20 @@ function createProvidersResponse(modelsByProvider: Record) { }; } +function createSessionMessagesResponse(providerID: string, modelID: string) { + return { + data: [ + { + info: { + providerID, + modelID, + }, + }, + ], + error: null, + }; +} + describe("model/manager", () => { let tempDir = ""; let originalXdgStateHome: string | undefined; @@ -121,6 +163,7 @@ describe("model/manager", () => { loggerDebugMock.mockReset(); providersMock.mockReset(); + sessionMessagesMock.mockReset(); providersMock.mockResolvedValue( createProvidersResponse({ opencode: ["big-pickle"], @@ -480,4 +523,139 @@ describe("model/manager", () => { expect(setCurrentModelMock).not.toHaveBeenCalled(); }); }); + + describe("fetchCurrentModelFromSession", () => { + const storedModel = { providerID: "openai", modelID: "gpt-4o", variant: "high" }; + + function setActiveSessionContext() { + setCurrentProjectState({ name: "test", path: "/test", worktree: "/test" }); + setCurrentSessionState({ id: "test-session", title: "Test", directory: "/test" }); + } + + it("returns stored model when no active session", async () => { + setCurrentModelState(storedModel); + setCurrentProjectState({ name: "test", path: "/test", worktree: "/test" }); + setCurrentSessionState(null); + + const result = await fetchCurrentModelFromSession(); + + expect(result).toEqual(storedModel); + expect(sessionMessagesMock).not.toHaveBeenCalled(); + expect(setCurrentModelMock).not.toHaveBeenCalled(); + }); + + it("returns stored model when no active project", async () => { + setCurrentModelState(storedModel); + setCurrentSessionState({ id: "test-session", title: "Test", directory: "/test" }); + setCurrentProjectState(undefined); + + const result = await fetchCurrentModelFromSession(); + + expect(result).toEqual(storedModel); + expect(sessionMessagesMock).not.toHaveBeenCalled(); + expect(setCurrentModelMock).not.toHaveBeenCalled(); + }); + + it("returns stored model when session messages API returns error", async () => { + setCurrentModelState(storedModel); + setActiveSessionContext(); + sessionMessagesMock.mockResolvedValueOnce({ data: null, error: new Error("failed") }); + + const result = await fetchCurrentModelFromSession(); + + expect(result).toEqual(storedModel); + expect(sessionMessagesMock).toHaveBeenCalledWith({ + sessionID: "test-session", + directory: "/test", + limit: 1, + }); + expect(setCurrentModelMock).not.toHaveBeenCalled(); + }); + + it("returns stored model when session has no messages", async () => { + setCurrentModelState(storedModel); + setActiveSessionContext(); + sessionMessagesMock.mockResolvedValueOnce({ data: [], error: null }); + + const result = await fetchCurrentModelFromSession(); + + expect(result).toEqual(storedModel); + expect(setCurrentModelMock).not.toHaveBeenCalled(); + }); + + it("returns stored model when message has no model info", async () => { + setCurrentModelState(storedModel); + setActiveSessionContext(); + sessionMessagesMock.mockResolvedValueOnce(createSessionMessagesResponse("", "")); + + const result = await fetchCurrentModelFromSession(); + + expect(result).toEqual(storedModel); + expect(setCurrentModelMock).not.toHaveBeenCalled(); + }); + + it("returns stored model when it matches session model", async () => { + setCurrentModelState(storedModel); + setActiveSessionContext(); + sessionMessagesMock.mockResolvedValueOnce( + createSessionMessagesResponse(storedModel.providerID, storedModel.modelID), + ); + + const result = await fetchCurrentModelFromSession(); + + expect(result).toEqual(storedModel); + expect(setCurrentModelMock).not.toHaveBeenCalled(); + }); + + it("syncs model from session when different from stored", async () => { + setCurrentModelState(storedModel); + setActiveSessionContext(); + sessionMessagesMock.mockResolvedValueOnce( + createSessionMessagesResponse("anthropic", "claude-sonnet"), + ); + + const result = await fetchCurrentModelFromSession(); + + expect(result).toEqual({ + providerID: "anthropic", + modelID: "claude-sonnet", + variant: "high", + }); + expect(setCurrentModelMock).toHaveBeenCalledWith({ + providerID: "anthropic", + modelID: "claude-sonnet", + variant: "high", + }); + }); + + it("preserves stored variant when syncing from session", async () => { + setCurrentModelState({ providerID: "openai", modelID: "gpt-4o", variant: "low" }); + setActiveSessionContext(); + sessionMessagesMock.mockResolvedValueOnce(createSessionMessagesResponse("google", "gemini-pro")); + + const result = await fetchCurrentModelFromSession(); + + expect(result).toEqual({ providerID: "google", modelID: "gemini-pro", variant: "low" }); + expect(setCurrentModelMock).toHaveBeenCalledWith({ + providerID: "google", + modelID: "gemini-pro", + variant: "low", + }); + }); + + it("returns stored model on API exception", async () => { + setCurrentModelState(storedModel); + setActiveSessionContext(); + sessionMessagesMock.mockRejectedValueOnce(new Error("network down")); + + const result = await fetchCurrentModelFromSession(); + + expect(result).toEqual(storedModel); + expect(setCurrentModelMock).not.toHaveBeenCalled(); + expect(loggerErrorMock).toHaveBeenCalledWith( + "[ModelManager] Error fetching model from session:", + expect.any(Error), + ); + }); + }); }); diff --git a/tests/rename/manager.test.ts b/tests/rename/manager.test.ts deleted file mode 100644 index ae6c97c3..00000000 --- a/tests/rename/manager.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { renameManager } from "../../src/rename/manager.js"; - -describe("renameManager", () => { - beforeEach(() => { - renameManager.clear(); - }); - - it("starts waiting for rename and tracks state", () => { - renameManager.startWaiting("session-123", "/path/to/project", "Old Title"); - - expect(renameManager.isWaitingForName()).toBe(true); - const info = renameManager.getSessionInfo(); - expect(info).toEqual({ - sessionId: "session-123", - directory: "/path/to/project", - currentTitle: "Old Title", - }); - }); - - it("tracks message ID for cleanup", () => { - renameManager.startWaiting("session-456", "/path", "Test"); - renameManager.setMessageId(42); - - expect(renameManager.getMessageId()).toBe(42); - expect(renameManager.isActiveMessage(42)).toBe(true); - expect(renameManager.isActiveMessage(99)).toBe(false); - }); - - it("clears state completely", () => { - renameManager.startWaiting("session-789", "/path", "Title"); - renameManager.setMessageId(100); - - renameManager.clear(); - - expect(renameManager.isWaitingForName()).toBe(false); - expect(renameManager.getSessionInfo()).toBeNull(); - expect(renameManager.getMessageId()).toBeNull(); - }); - - it("returns null session info when not waiting", () => { - expect(renameManager.isWaitingForName()).toBe(false); - expect(renameManager.getSessionInfo()).toBeNull(); - }); -});