Defer OpenCode restarts for config mutations
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
824d1fbbf4
commit
775da6e9f4
@@ -52,6 +52,25 @@ afterEach(() => {
|
||||
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
|
||||
describe('VS Code config bridge plugin parity', () => {
|
||||
test('explicit config reload restarts OpenCode', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-reload-'));
|
||||
tempRoots.push(root);
|
||||
const ctx = createCtx(root);
|
||||
|
||||
const reloaded = await handleConfigBridgeMessage({
|
||||
id: 'reload',
|
||||
type: 'api:config/reload',
|
||||
}, ctx, deps);
|
||||
|
||||
expect(reloaded).toEqual({
|
||||
id: 'reload',
|
||||
type: 'api:config/reload',
|
||||
success: true,
|
||||
data: { restarted: true },
|
||||
});
|
||||
expect(ctx.restart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('removes agent fields when update payload sends null', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-agent-null-'));
|
||||
tempRoots.push(root);
|
||||
@@ -102,7 +121,14 @@ describe('VS Code config bridge plugin parity', () => {
|
||||
}, ctx, deps);
|
||||
|
||||
expect(created?.success).toBe(true);
|
||||
expect(ctx.restart).toHaveBeenCalledTimes(1);
|
||||
expect(created?.data).toMatchObject({
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
requiresRestart: true,
|
||||
restartDeferred: true,
|
||||
message: 'Plugin entry changed. Restart OpenCode to apply.',
|
||||
});
|
||||
expect(ctx.restart).not.toHaveBeenCalled();
|
||||
|
||||
const listed = await handleConfigBridgeMessage({
|
||||
id: 'list',
|
||||
@@ -251,27 +277,36 @@ describe('VS Code config bridge plugin parity', () => {
|
||||
expect(fs.readFileSync(path.join(configDir, 'plugins', 'demo-plugin.ts'), 'utf8')).toBe('export default {}');
|
||||
});
|
||||
|
||||
test('reports plugin mutation success when restart fails after writing config', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugin-restart-'));
|
||||
test('creates MCP config with deferred restart when restart would fail', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-mcp-deferred-'));
|
||||
tempRoots.push(root);
|
||||
const ctx = createCtx(root, async () => {
|
||||
throw new Error('restart failed');
|
||||
});
|
||||
|
||||
const created = await handleConfigBridgeMessage({
|
||||
id: 'create-restart-failure',
|
||||
type: 'api:config/plugins',
|
||||
id: 'create-mcp-deferred',
|
||||
type: 'api:config/mcp',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
target: 'entry',
|
||||
name: 'mcp-deferred',
|
||||
directory: root,
|
||||
body: { scope: 'project', spec: 'plugin-restart' },
|
||||
body: { scope: 'project', type: 'local', command: ['node', 'server.js'] },
|
||||
},
|
||||
}, ctx, deps);
|
||||
|
||||
expect(created?.success).toBe(true);
|
||||
expect(created?.data).toMatchObject({ success: true, requiresReload: false, reloadFailed: true });
|
||||
expect(created?.data?.warning).toContain('restart failed');
|
||||
expect(readJson(path.join(root, '.opencode', 'opencode.json')).plugin).toEqual(['plugin-restart']);
|
||||
expect(created?.data).toMatchObject({
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
requiresRestart: true,
|
||||
restartDeferred: true,
|
||||
message: 'MCP server "mcp-deferred" created. Restart OpenCode to apply.',
|
||||
});
|
||||
expect(ctx.restart).not.toHaveBeenCalled();
|
||||
expect(readJson(path.join(root, '.opencode', 'opencode.json')).mcp['mcp-deferred']).toMatchObject({
|
||||
type: 'local',
|
||||
command: ['node', 'server.js'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
installSkillsFromRepository as installSkillsFromGit,
|
||||
type SkillsCatalogSourceConfig,
|
||||
} from './skillsCatalog';
|
||||
import { buildDeferredRestartResponse } from './config-mutation-response';
|
||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
@@ -84,31 +85,9 @@ const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: str
|
||||
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath)
|
||||
);
|
||||
|
||||
const pluginMutationPayload = async (
|
||||
ctx: BridgeContext | undefined,
|
||||
deps: ConfigRuntimeDeps,
|
||||
label: string,
|
||||
) => {
|
||||
try {
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `${label}. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
reloadFailed: false,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
message: `${label}, but OpenCode reload failed.`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
reloadFailed: true,
|
||||
warning: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
const pluginMutationPayload = (label: string) => buildDeferredRestartResponse(
|
||||
`${label}. Restart OpenCode to apply.`,
|
||||
);
|
||||
|
||||
const parseSkillsCatalogSources = (settings: Record<string, unknown>): SkillsCatalogSourceConfig[] => {
|
||||
const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs;
|
||||
@@ -222,8 +201,12 @@ export async function handleConfigBridgeMessage(
|
||||
}
|
||||
await fs.promises.mkdir(path.dirname(AGENTS_MD_PATH), { recursive: true });
|
||||
await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8');
|
||||
await ctx?.manager?.restart();
|
||||
return { id, type, success: true, data: { success: true } };
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: buildDeferredRestartResponse('AGENTS.md saved. Restart OpenCode to apply.'),
|
||||
};
|
||||
}
|
||||
|
||||
case 'api:magic-prompts:get': {
|
||||
@@ -295,33 +278,21 @@ export async function handleConfigBridgeMessage(
|
||||
const scopeValue = body?.scope as string | undefined;
|
||||
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
|
||||
createAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Agent ${agentName} created successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Agent ${agentName} updated successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -329,17 +300,11 @@ export async function handleConfigBridgeMessage(
|
||||
const scopeValue = body?.scope as string | undefined;
|
||||
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
|
||||
deleteAgent(agentName, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Agent ${agentName} deleted successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -378,49 +343,31 @@ export async function handleConfigBridgeMessage(
|
||||
const scopeValue = body?.scope as string | undefined;
|
||||
const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined;
|
||||
createCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Command ${commandName} created successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Command ${commandName} updated successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteCommand(commandName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Command ${commandName} deleted successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -458,49 +405,31 @@ export async function handleConfigBridgeMessage(
|
||||
if (normalizedMethod === 'POST') {
|
||||
const scope = body?.scope as 'user' | 'project' | undefined;
|
||||
createMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" created. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`MCP server "${mcpName}" created. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" updated. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`MCP server "${mcpName}" updated. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteMcpConfig(mcpName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${mcpName}" deleted. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`MCP server "${mcpName}" deleted. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -562,7 +491,7 @@ export async function handleConfigBridgeMessage(
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: await pluginMutationPayload(ctx, deps, 'Plugin entry changed'),
|
||||
data: pluginMutationPayload('Plugin entry changed'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -590,7 +519,7 @@ export async function handleConfigBridgeMessage(
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: await pluginMutationPayload(ctx, deps, 'Plugin file changed'),
|
||||
data: pluginMutationPayload('Plugin file changed'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -678,49 +607,31 @@ export async function handleConfigBridgeMessage(
|
||||
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
|
||||
const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode';
|
||||
createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record<string, unknown>, workingDirectory, scope);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Skill ${skillName} created successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'PATCH') {
|
||||
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Skill ${skillName} updated successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMethod === 'DELETE') {
|
||||
deleteSkill(skillName, workingDirectory);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
data: buildDeferredRestartResponse(`Skill ${skillName} deleted successfully. Restart OpenCode to apply.`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -773,11 +684,7 @@ export async function handleConfigBridgeMessage(
|
||||
if (data.ok) {
|
||||
const installed = data.installed || [];
|
||||
const skipped = data.skipped || [];
|
||||
const requiresReload = installed.length > 0;
|
||||
|
||||
if (requiresReload) {
|
||||
await ctx?.manager?.restart();
|
||||
}
|
||||
const requiresRestart = installed.length > 0;
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -787,9 +694,12 @@ export async function handleConfigBridgeMessage(
|
||||
ok: true,
|
||||
installed,
|
||||
skipped,
|
||||
requiresReload,
|
||||
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
||||
reloadDelayMs: requiresReload ? deps.clientReloadDelayMs : undefined,
|
||||
...(requiresRestart
|
||||
? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.')
|
||||
: {
|
||||
requiresReload: false,
|
||||
message: 'No skills were installed',
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
||||
import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials';
|
||||
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
|
||||
import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime';
|
||||
import { buildDeferredRestartResponse } from './config-mutation-response';
|
||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||
|
||||
type BridgeMessageInput = {
|
||||
@@ -443,21 +444,19 @@ export async function handleSystemBridgeMessage(
|
||||
return { id, type, success: false, error: 'Invalid scope' };
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
await ctx?.manager?.restart();
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
removed,
|
||||
requiresReload: removed,
|
||||
message: removed
|
||||
? `Provider ${providerId} disconnected successfully. Reloading interface…`
|
||||
: `Provider ${providerId} was not configured.`,
|
||||
reloadDelayMs: removed ? deps.clientReloadDelayMs : undefined,
|
||||
...(removed
|
||||
? buildDeferredRestartResponse(`Provider ${providerId} disconnected successfully. Restart OpenCode to apply.`)
|
||||
: {
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
message: `Provider ${providerId} was not configured.`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Shared response shape for OpenCode config mutations.
|
||||
*
|
||||
* Settings writes persist to disk immediately but defer the OpenCode restart
|
||||
* so the UI can accumulate pending changes and apply them once via
|
||||
* api:config/reload ("Apply & Restart OpenCode").
|
||||
*/
|
||||
export function buildDeferredRestartResponse(message: string) {
|
||||
return {
|
||||
success: true,
|
||||
requiresReload: false,
|
||||
requiresRestart: true,
|
||||
restartDeferred: true,
|
||||
message,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user