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/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/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 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(); - }); -});