From 03d74d3b109233c0aa90fea29cbb3bd97e895902 Mon Sep 17 00:00:00 2001 From: Vladyslav khrypchenko Date: Tue, 24 Mar 2026 09:21:19 +0000 Subject: [PATCH] fix(agent): fall back to an available project agent Keep the stored agent in sync with the selected project so switching worktrees does not send unsupported agent names to OpenCode. --- src/agent/manager.ts | 50 ++++++- src/bot/commands/commands.ts | 4 +- src/bot/commands/new.ts | 5 +- src/bot/commands/projects.ts | 5 +- src/bot/handlers/model.ts | 6 +- src/bot/handlers/prompt.ts | 7 +- src/bot/handlers/variant.ts | 6 +- tests/agent/manager.test.ts | 193 ++++++++++++++++++++++++++++ tests/bot/commands/commands.test.ts | 1 + 9 files changed, 258 insertions(+), 19 deletions(-) create mode 100644 tests/agent/manager.test.ts diff --git a/src/agent/manager.ts b/src/agent/manager.ts index a0fb15b..9b5cadf 100644 --- a/src/agent/manager.ts +++ b/src/agent/manager.ts @@ -40,6 +40,40 @@ export async function getAvailableAgents(): Promise { const DEFAULT_AGENT = "build"; +function pickFallbackAgent(agents: AgentInfo[]): string { + const defaultAgent = agents.find((agent) => agent.name === DEFAULT_AGENT); + if (defaultAgent) { + return defaultAgent.name; + } + + return agents[0]?.name ?? DEFAULT_AGENT; +} + +export async function resolveProjectAgent(preferredAgent?: string): Promise { + const requestedAgent = preferredAgent ?? getCurrentAgent() ?? DEFAULT_AGENT; + const project = getCurrentProject(); + + if (!project) { + return requestedAgent; + } + + const agents = await getAvailableAgents(); + if (agents.length === 0) { + return requestedAgent; + } + + if (agents.some((agent) => agent.name === requestedAgent)) { + return requestedAgent; + } + + const fallbackAgent = pickFallbackAgent(agents); + logger.warn( + `[AgentManager] Agent "${requestedAgent}" is not available for project ${project.worktree}. Falling back to "${fallbackAgent}".`, + ); + setCurrentAgent(fallbackAgent); + return fallbackAgent; +} + /** * Get current agent from last session message or settings. * Falls back to "build" if nothing is stored. @@ -50,11 +84,15 @@ export async function fetchCurrentAgent(): Promise { const session = getCurrentSession(); const project = getCurrentProject(); - if (!session || !project) { - // No active session, return stored agent from settings + if (!project) { + // No active project, return stored agent from settings return storedAgent ?? DEFAULT_AGENT; } + if (!session) { + return resolveProjectAgent(storedAgent ?? DEFAULT_AGENT); + } + try { const { data: messages, error } = await opencodeClient.session.messages({ sessionID: session.id, @@ -64,7 +102,7 @@ export async function fetchCurrentAgent(): Promise { if (error || !messages || messages.length === 0) { logger.debug("[AgentManager] No messages found, using stored agent"); - return storedAgent ?? DEFAULT_AGENT; + return resolveProjectAgent(storedAgent ?? DEFAULT_AGENT); } const lastAgent = messages[0].info.agent; @@ -76,7 +114,7 @@ export async function fetchCurrentAgent(): Promise { logger.debug( `[AgentManager] Using stored agent "${storedAgent}" instead of session agent "${lastAgent}"`, ); - return storedAgent; + return resolveProjectAgent(storedAgent); } // No stored agent yet: sync from session history @@ -84,10 +122,10 @@ export async function fetchCurrentAgent(): Promise { setCurrentAgent(lastAgent); } - return lastAgent || storedAgent || DEFAULT_AGENT; + return resolveProjectAgent(lastAgent || storedAgent || DEFAULT_AGENT); } catch (err) { logger.error("[AgentManager] Error fetching current agent:", err); - return storedAgent ?? DEFAULT_AGENT; + return resolveProjectAgent(storedAgent ?? DEFAULT_AGENT); } } diff --git a/src/bot/commands/commands.ts b/src/bot/commands/commands.ts index 5263f69..fbf52e8 100644 --- a/src/bot/commands/commands.ts +++ b/src/bot/commands/commands.ts @@ -11,7 +11,7 @@ import { ingestSessionInfoForCache } from "../../session/cache-manager.js"; import { interactionManager } from "../../interaction/manager.js"; import type { InteractionState } from "../../interaction/types.js"; import { summaryAggregator } from "../../summary/aggregator.js"; -import { getStoredAgent } from "../../agent/manager.js"; +import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js"; import { getStoredModel } from "../../model/manager.js"; import { safeBackgroundTask } from "../../utils/safe-background-task.js"; import { logger } from "../../utils/logger.js"; @@ -426,7 +426,7 @@ async function executeCommand( return; } - const currentAgent = getStoredAgent(); + const currentAgent = await resolveProjectAgent(getStoredAgent()); const storedModel = getStoredModel(); const model = storedModel.providerID && storedModel.modelID diff --git a/src/bot/commands/new.ts b/src/bot/commands/new.ts index 3f2dacf..9607795 100644 --- a/src/bot/commands/new.ts +++ b/src/bot/commands/new.ts @@ -7,7 +7,7 @@ import { clearAllInteractionState } from "../../interaction/cleanup.js"; import { summaryAggregator } from "../../summary/aggregator.js"; import { pinnedMessageManager } from "../../pinned/manager.js"; import { keyboardManager } from "../../keyboard/manager.js"; -import { getStoredAgent } from "../../agent/manager.js"; +import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js"; import { getStoredModel } from "../../model/manager.js"; import { formatVariantForButton } from "../../variant/manager.js"; import { createMainKeyboard } from "../utils/keyboard.js"; @@ -68,10 +68,11 @@ export async function newCommand(ctx: CommandContext) { } // Get current state for keyboard - const currentAgent = getStoredAgent(); + const currentAgent = await resolveProjectAgent(getStoredAgent()); const currentModel = getStoredModel(); const contextInfo = pinnedMessageManager.getContextInfo(); const variantName = formatVariantForButton(currentModel.variant || "default"); + keyboardManager.updateAgent(currentAgent); const keyboard = createMainKeyboard( currentAgent, currentModel, diff --git a/src/bot/commands/projects.ts b/src/bot/commands/projects.ts index 180a91b..5947dd0 100644 --- a/src/bot/commands/projects.ts +++ b/src/bot/commands/projects.ts @@ -7,7 +7,7 @@ import { clearSession } from "../../session/manager.js"; import { summaryAggregator } from "../../summary/aggregator.js"; import { pinnedMessageManager } from "../../pinned/manager.js"; import { keyboardManager } from "../../keyboard/manager.js"; -import { getStoredAgent } from "../../agent/manager.js"; +import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js"; import { getStoredModel } from "../../model/manager.js"; import { formatVariantForButton } from "../../variant/manager.js"; import { clearAllInteractionState } from "../../interaction/cleanup.js"; @@ -286,10 +286,11 @@ export async function handleProjectSelect(ctx: Context): Promise { keyboardManager.updateContext(0, contextLimit); // Get current state for keyboard (with context = 0) - const currentAgent = getStoredAgent(); + const currentAgent = await resolveProjectAgent(getStoredAgent()); const currentModel = getStoredModel(); const contextInfo = { tokensUsed: 0, tokensLimit: contextLimit }; const variantName = formatVariantForButton(currentModel.variant || "default"); + keyboardManager.updateAgent(currentAgent); const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo, variantName); const projectName = selectedProject.name || selectedProject.worktree; diff --git a/src/bot/handlers/model.ts b/src/bot/handlers/model.ts index e8d2082..0e11660 100644 --- a/src/bot/handlers/model.ts +++ b/src/bot/handlers/model.ts @@ -5,7 +5,7 @@ import type { FavoriteModel, ModelInfo, ModelSelectionLists } from "../../model/ import { formatVariantForButton } from "../../variant/manager.js"; import { logger } from "../../utils/logger.js"; import { createMainKeyboard } from "../utils/keyboard.js"; -import { getStoredAgent } from "../../agent/manager.js"; +import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js"; import { pinnedMessageManager } from "../../pinned/manager.js"; import { keyboardManager } from "../../keyboard/manager.js"; import { @@ -83,13 +83,15 @@ export async function handleModelSelect(ctx: Context): Promise { await pinnedMessageManager.refreshContextLimit(); // Update Reply Keyboard with new model and context - const currentAgent = getStoredAgent(); + const currentAgent = await resolveProjectAgent(getStoredAgent()); const contextInfo = pinnedMessageManager.getContextInfo() ?? (pinnedMessageManager.getContextLimit() > 0 ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() } : null); + keyboardManager.updateAgent(currentAgent); + if (contextInfo) { keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit); } diff --git a/src/bot/handlers/prompt.ts b/src/bot/handlers/prompt.ts index f80a227..24a123f 100644 --- a/src/bot/handlers/prompt.ts +++ b/src/bot/handlers/prompt.ts @@ -4,7 +4,7 @@ import { opencodeClient } from "../../opencode/client.js"; import { clearSession, getCurrentSession, setCurrentSession } from "../../session/manager.js"; import { ingestSessionInfoForCache } from "../../session/cache-manager.js"; import { getCurrentProject } from "../../settings/manager.js"; -import { getStoredAgent } from "../../agent/manager.js"; +import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js"; import { getStoredModel } from "../../model/manager.js"; import { formatVariantForButton } from "../../variant/manager.js"; import { createMainKeyboard } from "../utils/keyboard.js"; @@ -156,10 +156,11 @@ export async function processUserPrompt( logger.error("[Bot] Error creating pinned message for new session:", err); } - const currentAgent = getStoredAgent(); + const currentAgent = await resolveProjectAgent(getStoredAgent()); const currentModel = getStoredModel(); const contextInfo = pinnedMessageManager.getContextInfo(); const variantName = formatVariantForButton(currentModel.variant || "default"); + keyboardManager.updateAgent(currentAgent); const keyboard = createMainKeyboard( currentAgent, currentModel, @@ -198,7 +199,7 @@ export async function processUserPrompt( } try { - const currentAgent = getStoredAgent(); + const currentAgent = await resolveProjectAgent(getStoredAgent()); const storedModel = getStoredModel(); // Build parts array with text and files diff --git a/src/bot/handlers/variant.ts b/src/bot/handlers/variant.ts index b8de6a4..3ff3559 100644 --- a/src/bot/handlers/variant.ts +++ b/src/bot/handlers/variant.ts @@ -7,7 +7,7 @@ import { formatVariantForButton, } from "../../variant/manager.js"; import { getStoredModel } from "../../model/manager.js"; -import { getStoredAgent } from "../../agent/manager.js"; +import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js"; import { logger } from "../../utils/logger.js"; import { keyboardManager } from "../../keyboard/manager.js"; import { pinnedMessageManager } from "../../pinned/manager.js"; @@ -70,13 +70,15 @@ export async function handleVariantSelect(ctx: Context): Promise { keyboardManager.updateVariant(variantId); // Build keyboard with correct context info - const currentAgent = getStoredAgent(); + const currentAgent = await resolveProjectAgent(getStoredAgent()); const contextInfo = pinnedMessageManager.getContextInfo() ?? (pinnedMessageManager.getContextLimit() > 0 ? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() } : null); + keyboardManager.updateAgent(currentAgent); + if (contextInfo) { keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit); } diff --git a/tests/agent/manager.test.ts b/tests/agent/manager.test.ts new file mode 100644 index 0000000..7167632 --- /dev/null +++ b/tests/agent/manager.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocked = vi.hoisted(() => { + let currentProject: + | { + id: string; + worktree: string; + name: string; + } + | undefined; + let currentSession: + | { + id: string; + directory: string; + title: string; + } + | undefined; + let currentAgent: string | undefined; + + const appAgentsMock = vi.fn(); + const sessionMessagesMock = vi.fn(); + const getCurrentProjectMock = vi.fn(() => currentProject); + const getCurrentSessionMock = vi.fn(() => currentSession); + const getCurrentAgentMock = vi.fn(() => currentAgent); + const setCurrentAgentMock = vi.fn((agentName: string) => { + currentAgent = agentName; + }); + + return { + appAgentsMock, + sessionMessagesMock, + getCurrentProjectMock, + getCurrentSessionMock, + getCurrentAgentMock, + setCurrentAgentMock, + loggerDebugMock: vi.fn(), + loggerErrorMock: vi.fn(), + loggerInfoMock: vi.fn(), + loggerWarnMock: vi.fn(), + setCurrentProject: (project?: { id: string; worktree: string; name: string }) => { + currentProject = project; + }, + setCurrentSession: (session?: { id: string; directory: string; title: string }) => { + currentSession = session; + }, + setCurrentAgent: (agentName?: string) => { + currentAgent = agentName; + }, + }; +}); + +vi.mock("../../src/opencode/client.js", () => ({ + opencodeClient: { + app: { + agents: mocked.appAgentsMock, + }, + session: { + messages: mocked.sessionMessagesMock, + }, + }, +})); + +vi.mock("../../src/settings/manager.js", () => ({ + getCurrentProject: mocked.getCurrentProjectMock, + getCurrentAgent: mocked.getCurrentAgentMock, + setCurrentAgent: mocked.setCurrentAgentMock, +})); + +vi.mock("../../src/session/manager.js", () => ({ + getCurrentSession: mocked.getCurrentSessionMock, +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { + debug: mocked.loggerDebugMock, + error: mocked.loggerErrorMock, + info: mocked.loggerInfoMock, + warn: mocked.loggerWarnMock, + }, +})); + +import { fetchCurrentAgent, getAvailableAgents, resolveProjectAgent } from "../../src/agent/manager.js"; + +function createAgentResponse( + agents: Array<{ name: string; mode: "primary" | "all" | "subagent"; hidden?: boolean }>, +) { + return { + data: agents, + error: null, + }; +} + +describe("agent/manager", () => { + beforeEach(() => { + mocked.appAgentsMock.mockReset(); + mocked.sessionMessagesMock.mockReset(); + mocked.getCurrentProjectMock.mockClear(); + mocked.getCurrentSessionMock.mockClear(); + mocked.getCurrentAgentMock.mockClear(); + mocked.setCurrentAgentMock.mockClear(); + mocked.loggerDebugMock.mockReset(); + mocked.loggerErrorMock.mockReset(); + mocked.loggerInfoMock.mockReset(); + mocked.loggerWarnMock.mockReset(); + mocked.setCurrentProject(undefined); + mocked.setCurrentSession(undefined); + mocked.setCurrentAgent(undefined); + }); + + it("filters out hidden agents and subagents", async () => { + mocked.setCurrentProject({ + id: "project-1", + worktree: "/workspace/project-1", + name: "project-1", + }); + mocked.appAgentsMock.mockResolvedValue( + createAgentResponse([ + { name: "orchestrator", mode: "primary" }, + { name: "build", mode: "primary" }, + { name: "summary", mode: "primary", hidden: true }, + { name: "general", mode: "subagent" }, + ]), + ); + + const result = await getAvailableAgents(); + + expect(result).toEqual([ + { name: "orchestrator", mode: "primary" }, + { name: "build", mode: "primary" }, + ]); + }); + + it("falls back to build when the preferred agent is unavailable in the project", async () => { + mocked.setCurrentProject({ + id: "project-1", + worktree: "/workspace/project-1", + name: "project-1", + }); + mocked.setCurrentAgent("orchestrator"); + mocked.appAgentsMock.mockResolvedValue( + createAgentResponse([ + { name: "build", mode: "primary" }, + { name: "plan", mode: "primary" }, + ]), + ); + + const result = await resolveProjectAgent("orchestrator"); + + expect(result).toBe("build"); + expect(mocked.setCurrentAgentMock).toHaveBeenCalledWith("build"); + expect(mocked.loggerWarnMock).toHaveBeenCalledOnce(); + }); + + it("falls back to the first available agent when build is unavailable", async () => { + mocked.setCurrentProject({ + id: "project-2", + worktree: "/workspace/project-2", + name: "project-2", + }); + mocked.appAgentsMock.mockResolvedValue( + createAgentResponse([ + { name: "plan", mode: "primary" }, + { name: "orchestrator", mode: "primary" }, + ]), + ); + + const result = await resolveProjectAgent("build"); + + expect(result).toBe("plan"); + expect(mocked.setCurrentAgentMock).toHaveBeenCalledWith("plan"); + }); + + it("normalizes an invalid stored agent when there is an active project without a session", async () => { + mocked.setCurrentProject({ + id: "project-3", + worktree: "/workspace/project-3", + name: "project-3", + }); + mocked.setCurrentAgent("orchestrator"); + mocked.appAgentsMock.mockResolvedValue( + createAgentResponse([ + { name: "build", mode: "primary" }, + { name: "plan", mode: "primary" }, + ]), + ); + + const result = await fetchCurrentAgent(); + + expect(result).toBe("build"); + expect(mocked.setCurrentAgentMock).toHaveBeenCalledWith("build"); + expect(mocked.sessionMessagesMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/bot/commands/commands.test.ts b/tests/bot/commands/commands.test.ts index 4b8e769..91982f2 100644 --- a/tests/bot/commands/commands.test.ts +++ b/tests/bot/commands/commands.test.ts @@ -81,6 +81,7 @@ vi.mock("../../../src/summary/aggregator.js", () => ({ vi.mock("../../../src/agent/manager.js", () => ({ getStoredAgent: vi.fn(() => "build"), + resolveProjectAgent: vi.fn(async (agentName?: string) => agentName ?? "build"), })); vi.mock("../../../src/model/manager.js", () => ({