From ef71a997a8ef8c12cde591f1c233797325e04c4b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 8 Jun 2026 13:43:45 +0300 Subject: [PATCH] fix: save agent prompt and reload changes reliably Agent settings now correctly save an emptied system prompt instead of silently keeping the old text. After saving agent, command, or skill settings, OpenChamber refreshes the updated configuration instead of showing stale values from a short-lived cache. Reload actions now surface refresh failures consistently while avoiding noisy background errors. --- .../components/sections/agents/AgentsPage.tsx | 3 +- .../components/update/OpenCodeUpdateToast.tsx | 2 +- .../ui/src/components/views/SettingsView.tsx | 2 +- packages/ui/src/stores/useAgentsStore.ts | 19 ++++++----- packages/ui/src/stores/useCommandsStore.ts | 9 +++++ .../ui/src/stores/useSkillsCatalogStore.ts | 3 +- packages/ui/src/stores/useSkillsStore.ts | 11 ++++++- packages/vscode/src/opencodeConfig.ts | 33 +++++++++++++++++++ packages/vscode/webview/main.tsx | 2 +- packages/web/server/lib/opencode/agents.js | 33 +++++++++++++++++++ 10 files changed, 103 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index 5d7b182a..277fe1e6 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -578,6 +578,7 @@ export const AgentsPage: React.FC = () => { try { const trimmedModel = model.trim(); + const trimmedPrompt = prompt.trim(); const permissionConfig = buildPermissionConfigWithGlobal(globalPermission, permissionRules); const config: AgentConfig = { name: agentName, @@ -586,7 +587,7 @@ export const AgentsPage: React.FC = () => { model: trimmedModel === '' ? null : trimmedModel, temperature, top_p: topP, - prompt: prompt.trim() || undefined, + prompt: trimmedPrompt || (isNewAgent ? undefined : null), permission: permissionConfig, scope: isNewAgent ? draftScope : undefined, }; diff --git a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx index f6deda11..7a4a321c 100644 --- a/packages/ui/src/components/update/OpenCodeUpdateToast.tsx +++ b/packages/ui/src/components/update/OpenCodeUpdateToast.tsx @@ -38,7 +38,7 @@ export const OpenCodeUpdateToast: React.FC = () => { message: t('opencodeUpdate.toast.reload.message'), mode: 'projects', scopes: ['all'], - }); + }).catch(() => undefined); }, [t]); const runUpgrade = React.useCallback(async () => { diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index b11bafa7..a07d43ff 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -749,7 +749,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile 'text-sm font-semibold text-sidebar-foreground/90', 'hover:text-sidebar-foreground hover:bg-interactive-hover', )} - onClick={() => void reloadOpenCodeConfiguration({ message: 'Restarting OpenCode…', mode: 'projects', scopes: ['all'] })} + onClick={() => void reloadOpenCodeConfiguration({ message: 'Restarting OpenCode…', mode: 'projects', scopes: ['all'] }).catch(() => undefined)} > {t('settings.view.actions.reloadOpenCode')} diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index 0f4fe455..aa083528 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -11,10 +11,10 @@ import { } from "@/lib/configUpdate"; import { getSafeStorage } from "./utils/safeStorage"; import { useConfigStore } from "@/stores/useConfigStore"; -import { useCommandsStore } from "@/stores/useCommandsStore"; +import { invalidateCommandsLoadCache, useCommandsStore } from "@/stores/useCommandsStore"; import { useProjectsStore } from "@/stores/useProjectsStore"; import { useSkillsCatalogStore } from "@/stores/useSkillsCatalogStore"; -import { useSkillsStore } from "@/stores/useSkillsStore"; +import { invalidateSkillsLoadCache, useSkillsStore } from "@/stores/useSkillsStore"; import { runtimeFetch } from "@/lib/runtime-fetch"; // Note: useDirectoryStore cannot be imported at top level to avoid circular dependency @@ -106,7 +106,7 @@ export interface AgentConfig { model?: string | null; temperature?: number; top_p?: number; - prompt?: string; + prompt?: string | null; mode?: "primary" | "subagent" | "all"; permission?: PermissionConfig | null; @@ -364,7 +364,7 @@ export const useAgentsStore = create()( message: payload?.message, delayMs: payload?.reloadDelayMs, scopes: ["agents"], - mode: "active", + mode: "projects", }); return true; } @@ -426,7 +426,7 @@ export const useAgentsStore = create()( message: payload?.message, delayMs: payload?.reloadDelayMs, scopes: ["agents"], - mode: "active", + mode: "projects", }); return true; } @@ -473,7 +473,7 @@ export const useAgentsStore = create()( message: payload?.message, delayMs: payload?.reloadDelayMs, scopes: ["agents"], - mode: "active", + mode: "projects", }); return true; } @@ -648,18 +648,21 @@ async function performConfigRefresh(options: { uiRefreshTasks.push(agentConfigStore.loadAgents().then(() => undefined)); } if (refreshCommands) { + invalidateCommandsLoadCache(currentDirectory); uiRefreshTasks.push(commandsStore.loadCommands().then(() => undefined)); } if (refreshSkills) { + invalidateSkillsLoadCache(currentDirectory); uiRefreshTasks.push(skillsStore.loadSkills().then(() => undefined)); - uiRefreshTasks.push(skillsCatalogStore.loadCatalog().then(() => undefined)); + uiRefreshTasks.push(skillsCatalogStore.loadCatalog({ refresh: true }).then(() => undefined)); } updateConfigUpdateMessage("Refreshing configuration…"); await Promise.all([...sdkRefreshTasks, ...uiRefreshTasks]); - } catch { + } catch (error) { updateConfigUpdateMessage("OpenCode refresh failed. Please retry."); await sleep(1500); + throw error; } finally { finishConfigUpdate(); } diff --git a/packages/ui/src/stores/useCommandsStore.ts b/packages/ui/src/stores/useCommandsStore.ts index 92306b55..a3accc94 100644 --- a/packages/ui/src/stores/useCommandsStore.ts +++ b/packages/ui/src/stores/useCommandsStore.ts @@ -47,6 +47,10 @@ const getCommandsCacheKey = (directory: string | null): string => { return directory?.trim() || DEFAULT_COMMANDS_CACHE_KEY; }; +export const invalidateCommandsLoadCache = (directory: string | null = getRequestDirectory()) => { + commandsLastLoadedAt.delete(getCommandsCacheKey(directory)); +}; + const buildCommandsSignature = (commands: Command[]): string => { return commands .map((command) => [ @@ -277,6 +281,7 @@ export const useCommandsStore = create()( console.log('[CommandsStore] Command created successfully'); const needsReload = payload?.requiresReload ?? true; + invalidateCommandsLoadCache(directory); if (needsReload) { requiresReload = true; await performFullConfigRefresh({ @@ -338,6 +343,7 @@ export const useCommandsStore = create()( console.log('[CommandsStore] Command updated successfully'); const needsReload = payload?.requiresReload ?? true; + invalidateCommandsLoadCache(directory); if (needsReload) { requiresReload = true; await performFullConfigRefresh({ @@ -384,6 +390,7 @@ export const useCommandsStore = create()( console.log('[CommandsStore] Command deleted successfully'); const needsReload = payload?.requiresReload ?? true; + invalidateCommandsLoadCache(directory); if (needsReload) { requiresReload = true; await performFullConfigRefresh({ @@ -495,6 +502,7 @@ async function performFullConfigRefresh(options: { message?: string; delayMs?: n const commandsStore = useCommandsStore.getState(); + invalidateCommandsLoadCache(); await commandsStore.loadCommands(); emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE }); @@ -502,6 +510,7 @@ async function performFullConfigRefresh(options: { message?: string; delayMs?: n console.error("[CommandsStore] Failed to refresh configuration after OpenCode restart:", error); updateConfigUpdateMessage("OpenCode refresh failed. Please retry refreshing configuration manually."); await sleep(1500); + throw error; } finally { finishConfigUpdate(); } diff --git a/packages/ui/src/stores/useSkillsCatalogStore.ts b/packages/ui/src/stores/useSkillsCatalogStore.ts index 9097f905..139348fb 100644 --- a/packages/ui/src/stores/useSkillsCatalogStore.ts +++ b/packages/ui/src/stores/useSkillsCatalogStore.ts @@ -13,7 +13,7 @@ import type { SkillsCatalogSourceResponse, } from '@/lib/api/types'; -import { refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore'; +import { invalidateSkillsLoadCache, refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -423,6 +423,7 @@ export const useSkillsCatalogStore = create()( }); } else { updateConfigUpdateMessage(payload.message || 'Refreshing skills…'); + invalidateSkillsLoadCache(currentDirectory); void useSkillsStore.getState().loadSkills(); } diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index a7c8da91..b59ec55c 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -168,6 +168,10 @@ const getSkillsCacheKey = (directory: string | null): string => { return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY; }; +export const invalidateSkillsLoadCache = (directory: string | null = getCurrentDirectory()) => { + skillsLastLoadedAt.delete(getSkillsCacheKey(directory)); +}; + const MAX_HEALTH_WAIT_MS = 20000; const FAST_HEALTH_POLL_INTERVAL_MS = 300; const FAST_HEALTH_POLL_ATTEMPTS = 4; @@ -302,6 +306,7 @@ export const useSkillsStore = create()( } const needsReload = payload?.requiresReload ?? false; + invalidateSkillsLoadCache(currentDirectory); if (needsReload) { requiresReload = true; await refreshSkillsAfterOpenCodeRestart({ @@ -352,6 +357,7 @@ export const useSkillsStore = create()( } const needsReload = payload?.requiresReload ?? false; + invalidateSkillsLoadCache(currentDirectory); if (needsReload) { requiresReload = true; await refreshSkillsAfterOpenCodeRestart({ @@ -393,6 +399,7 @@ export const useSkillsStore = create()( } const needsReload = payload?.requiresReload ?? false; + invalidateSkillsLoadCache(currentDirectory); if (needsReload) { requiresReload = true; await refreshSkillsAfterOpenCodeRestart({ @@ -554,13 +561,15 @@ export async function refreshSkillsAfterOpenCodeRestart(options?: { message?: st await waitForOpenCodeConnection(options?.delayMs); updateConfigUpdateMessage("Refreshing skills…"); const skillsStore = useSkillsStore.getState(); + invalidateSkillsLoadCache(); const loaded = await skillsStore.loadSkills(); if (loaded) { emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE }); } - } catch { + } catch (error) { updateConfigUpdateMessage("OpenCode refresh failed. Please retry."); await sleep(1500); + throw error; } finally { finishConfigUpdate(); } diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 80c79a4b..5b6e28cc 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -1666,6 +1666,39 @@ export const updateAgent = (agentName: string, updates: Record, for (const [field, value] of Object.entries(updates || {})) { if (field === 'prompt') { + if (value === null) { + if (mdExists || creatingNewMd) { + if (mdData) { + mdData.body = ''; + mdModified = true; + } + continue; + } + + if (isPromptFileReference(jsonSection?.prompt)) { + const promptFilePath = resolvePromptFilePath(jsonSection.prompt); + if (!promptFilePath) throw new Error(`Invalid prompt file reference for agent ${agentName}`); + writePromptFile(promptFilePath, ''); + continue; + } + + if (config.agent) { + const agentMap = config.agent as Record; + const current = agentMap[agentName] as Record | undefined; + if (current) { + delete current.prompt; + if (Object.keys(current).length === 0) { + delete agentMap[agentName]; + } + if (Object.keys(agentMap).length === 0) { + delete config.agent; + } + jsonModified = true; + } + } + continue; + } + const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value); if (mdExists || creatingNewMd) { diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 1756fe9c..8434d7f6 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -1354,7 +1354,7 @@ onCommand('showSettings', () => { // and refreshes config/data. Triggered by the "Restart API Connection" command. onCommand('reloadOpenCode', () => { void import('@openchamber/ui/stores/useAgentsStore').then(({ reloadOpenCodeConfiguration }) => { - void reloadOpenCodeConfiguration(); + void reloadOpenCodeConfiguration().catch(() => undefined); }); }); diff --git a/packages/web/server/lib/opencode/agents.js b/packages/web/server/lib/opencode/agents.js index 06932df0..c2a25106 100644 --- a/packages/web/server/lib/opencode/agents.js +++ b/packages/web/server/lib/opencode/agents.js @@ -449,6 +449,39 @@ function updateAgent(agentName, updates, workingDirectory) { for (const [field, value] of Object.entries(updates)) { if (field === 'prompt') { + if (value === null) { + if (mdExists || creatingNewMd) { + if (mdData) { + mdData.body = ''; + mdModified = true; + } + continue; + } + + if (isPromptFileReference(jsonSection?.prompt)) { + const promptFilePath = resolvePromptFilePath(jsonSection.prompt); + if (!promptFilePath) { + throw new Error(`Invalid prompt file reference for agent ${agentName}`); + } + writePromptFile(promptFilePath, ''); + continue; + } + + if (config.agent?.[agentName]) { + delete config.agent[agentName].prompt; + + if (Object.keys(config.agent[agentName]).length === 0) { + delete config.agent[agentName]; + } + if (Object.keys(config.agent).length === 0) { + delete config.agent; + } + + jsonModified = true; + } + continue; + } + const normalizedValue = typeof value === 'string' ? value : (value == null ? '' : String(value)); if (mdExists || creatingNewMd) {