From 8e6fedceaa0167b65776dbdeddc2e4edabdce964 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 9 Jun 2026 00:53:30 +0800 Subject: [PATCH 1/2] add global and project setting --- packages/codingcode/src/agent/agent.ts | 29 +- packages/codingcode/src/agent/prompt.ts | 15 +- packages/codingcode/src/client/direct.ts | 38 +- .../codingcode/src/client/direct/settings.ts | 121 +++-- packages/codingcode/src/client/http.ts | 38 +- .../codingcode/src/client/http/settings.ts | 51 +- packages/codingcode/src/client/types.ts | 17 +- packages/codingcode/src/hooks/config.ts | 125 +++++ packages/codingcode/src/hooks/registry.ts | 8 +- packages/codingcode/src/mcp/config.ts | 125 +++++ packages/codingcode/src/mcp/index.ts | 8 +- .../codingcode/src/runtime/project-runtime.ts | 12 +- packages/codingcode/src/server/index.ts | 1 + .../codingcode/src/server/routes/settings.ts | 474 ++++++++++++++---- packages/codingcode/src/skills/config.ts | 115 ++++- packages/codingcode/src/skills/index.ts | 30 +- packages/codingcode/src/subagent/loader.ts | 54 +- packages/codingcode/src/subagent/registry.ts | 174 +++++-- .../src/tools/domains/subagent/dispatch.ts | 30 +- .../test/client/direct/settings.test.ts | 216 ++++++++ .../test/client/http/settings.test.ts | 140 ++++++ .../codingcode/test/context/context.test.ts | 3 +- .../test/hooks/config-merge.test.ts | 127 +++++ .../codingcode/test/mcp/config-merge.test.ts | 155 ++++++ packages/codingcode/test/mcp/service.test.ts | 9 +- packages/codingcode/test/orchestrate.test.ts | 3 +- .../codingcode/test/server/handler.test.ts | 3 +- .../test/server/settings-routes.test.ts | 395 ++++++++++++++- .../codingcode/test/subagent/dispatch.test.ts | 10 +- .../codingcode/test/subagent/registry.test.ts | 73 +-- .../codingcode/test/subagent/switch.test.ts | 252 ++++++++++ packages/desktop/src/agent/AgentSidebar.tsx | 39 +- packages/desktop/src/agent/ProjectStrip.tsx | 28 ++ packages/desktop/src/layouts/AgentLayout.tsx | 27 +- packages/desktop/src/lib/core-api.ts | 43 +- .../src/settings/GlobalSettingsPage.tsx | 98 ++++ packages/desktop/src/settings/HooksPanel.tsx | 32 +- packages/desktop/src/settings/McpPanel.tsx | 35 +- .../src/settings/ProjectSettingsPage.tsx | 68 +++ .../desktop/src/settings/SettingsPage.tsx | 124 ----- packages/desktop/src/settings/SkillPanel.tsx | 32 +- .../desktop/src/settings/SubagentsPanel.tsx | 63 ++- packages/desktop/src/stores/global.store.ts | 3 +- packages/desktop/test/core-api.test.ts | 185 +++++++ packages/desktop/test/settings-panels.test.ts | 230 +++++++++ packages/tui/src/components/App.tsx | 11 +- 46 files changed, 3281 insertions(+), 588 deletions(-) create mode 100644 packages/codingcode/test/client/direct/settings.test.ts create mode 100644 packages/codingcode/test/client/http/settings.test.ts create mode 100644 packages/codingcode/test/hooks/config-merge.test.ts create mode 100644 packages/codingcode/test/mcp/config-merge.test.ts create mode 100644 packages/codingcode/test/subagent/switch.test.ts create mode 100644 packages/desktop/src/settings/GlobalSettingsPage.tsx create mode 100644 packages/desktop/src/settings/ProjectSettingsPage.tsx delete mode 100644 packages/desktop/src/settings/SettingsPage.tsx create mode 100644 packages/desktop/test/core-api.test.ts diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index a064b44..b1002b7 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -21,6 +21,7 @@ import { McpService } from '../mcp/index.js'; import { loadMemoryForPrompt, flushSessionToMemory } from '../memory/index.js'; import { createLogger } from '@codingcode/infra'; import type { AgentProfile } from '../subagent/registry'; +import { resolveSubagentEnabled, resolveAgentDisabled } from '../subagent/registry.js'; import type { ToolVisibilityPolicy } from '../tools/types'; import { ProjectRuntimeService } from '../runtime/project-runtime'; import { createDispatchAgentTool } from '../tools/domains/subagent/dispatch.js'; @@ -71,26 +72,29 @@ export const sendMessage = ( // Apply main agent profile: model override, maxSteps, readonly, hooks, MCP let activeLlm = llm; - if (profile.model) { + if (profile?.model) { const entry = findModel(profile.model); if (entry) { const clientResult = yield* Effect.promise(() => createClient(entry)); if (clientResult.ok) activeLlm = clientResult.value; } } - const effectiveMaxSteps = profile.maxSteps; - const effectiveApproval: any = profile.readonly + const effectiveMaxSteps = profile?.maxSteps; + const effectiveApproval: any = profile?.readonly ? { permissionMode: 'bypass' } : options?.approvalOverride; - if (profile.hooks?.length) { + if (profile?.hooks?.length) { yield* hooks.attachSessionHooks(sid, profile.hooks); } - if (profile.mcpServers?.length) { + if (profile?.mcpServers?.length) { yield* mcp.connectServers(normalizedCwd, sid, profile.mcpServers); } + // Get MCP tools for injection into ReAct loop + const mcpTools = mcp.listProjectMcpTools(normalizedCwd); + const turnId = session.incrementTurn(state); const [matchedSkill, actualInput] = yield* skill.extractSkill(state.cwd, input); @@ -106,6 +110,7 @@ export const sendMessage = ( maxStepsOverride: effectiveMaxSteps, approvalOverride: effectiveApproval, dispatchTool, + mcpTools, skillInstruction: matchedSkill?.instruction, abortSignal: options?.signal, }); @@ -174,6 +179,7 @@ export interface RunStreamOptions { coreAllowlist?: ReadonlySet; toolPolicy?: ToolVisibilityPolicy; dispatchTool?: ToolDefinition; + mcpTools?: ToolDefinition[]; abortSignal?: AbortSignal; parentSessionId?: string; agentName?: string; @@ -239,8 +245,11 @@ export async function* runReActLoop( const sessionId = state.sessionId; const projectPath = state.cwd; - // Build system prompt - const agentProfiles = deps.runtime.listAgentProfiles(projectPath); + // Build system prompt — filter agent profiles by global switch and per-agent disabled state + const allAgentProfiles = deps.runtime.listAgentProfiles(projectPath); + const agentProfiles = resolveSubagentEnabled(projectPath) + ? allAgentProfiles.filter((p) => !resolveAgentDisabled(projectPath, p.name)) + : []; const basePrompt = opts.systemOverride ?? buildSystemPrompt({ @@ -308,9 +317,9 @@ export async function* runReActLoop( return Result.err(new AgentError('AGENT_ABORTED', 'cancelled')); } - // Build tools from static builtin + dispatch - let allToolDefs: ToolDefinition[] = [...STATIC_BUILTIN_TOOLS]; - if (opts.dispatchTool) allToolDefs = [...allToolDefs, opts.dispatchTool]; + // Build tools from static builtin + MCP + dispatch + let allToolDefs: ToolDefinition[] = [...STATIC_BUILTIN_TOOLS, ...(opts.mcpTools ?? [])]; + if (opts.dispatchTool && resolveSubagentEnabled(projectPath)) allToolDefs = [...allToolDefs, opts.dispatchTool]; // Apply policy filter (derived from AgentProfile.tools via getToolPolicy) const allowedByPolicy = opts.toolPolicy?.allowedTools; diff --git a/packages/codingcode/src/agent/prompt.ts b/packages/codingcode/src/agent/prompt.ts index 4d9b01e..82ad741 100644 --- a/packages/codingcode/src/agent/prompt.ts +++ b/packages/codingcode/src/agent/prompt.ts @@ -56,12 +56,15 @@ export function buildSystemPrompt(opts: SystemPromptOptions): string { } if (opts.agentProfiles && opts.agentProfiles.length > 0) { - prompt += '\n\n## Available Subagents\n'; - prompt += `You can dispatch subagents using the dispatch_agent tool. Available profiles:\n`; - for (const p of opts.agentProfiles) { - prompt += `\n### ${p.name}\n${p.description}`; - if (p.tools && p.tools.length > 0) { - prompt += `\nTools: ${p.tools.join(', ')}`; + const enabledProfiles = opts.agentProfiles.filter((p) => !p.disabled); + if (enabledProfiles.length > 0) { + prompt += '\n\n## Available Subagents\n'; + prompt += `You can dispatch subagents using the dispatch_agent tool. Available profiles:\n`; + for (const p of enabledProfiles) { + prompt += `\n### ${p.name}\n${p.description}`; + if (p.tools && p.tools.length > 0) { + prompt += `\nTools: ${p.tools.join(', ')}`; + } } } } diff --git a/packages/codingcode/src/client/direct.ts b/packages/codingcode/src/client/direct.ts index 8b7ae12..6278314 100644 --- a/packages/codingcode/src/client/direct.ts +++ b/packages/codingcode/src/client/direct.ts @@ -396,23 +396,27 @@ export async function createDirectClient(llm: any): Promise { }, async getSubagentEnabled() { - return clients.settings.getSubagentEnabled(); + return clients.settings.getSubagentEnabled({ cwd: cwd() }); }, - async setSubagentEnabled(enabled: boolean) { - await clients.settings.setSubagentEnabled(enabled); + async setSubagentEnabled(body: { enabled: boolean; cwd: string }) { + await clients.settings.setSubagentEnabled(body); + }, + + async resetSubagentEnabled(body: { cwd: string }) { + await clients.settings.resetSubagentEnabled(body); }, async getMcpStatus() { return clients.settings.getMcpStatus(); }, - async disableMcp(name: string) { - await clients.settings.setMcpDisabled(name, true); + async setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }) { + await clients.settings.setMcpDisabled(body); }, - async enableMcp(name: string) { - await clients.settings.setMcpDisabled(name, false); + async resetMcpDisabled(body: { name: string; cwd: string }) { + await clients.settings.resetMcpDisabled(body); }, async createMcpServer(server: any): Promise { @@ -431,8 +435,8 @@ export async function createDirectClient(llm: any): Promise { return clients.settings.listSkills(); }, - async toggleSkill(name: string, enabled: boolean) { - await clients.settings.toggleSkill(name, enabled); + async toggleSkill(body: { name: string; enabled: boolean; cwd: string }) { + await clients.settings.toggleSkill(body); }, async listAgents() { @@ -451,16 +455,24 @@ export async function createDirectClient(llm: any): Promise { await clients.settings.deleteAgent({ cwd: cwd(), name }); }, - async setAgentDisabled(name: string, disabled: boolean): Promise { - await clients.settings.setAgentDisabled(name, disabled); + async setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise { + await clients.settings.setAgentDisabled(body); + }, + + async resetAgentDisabled(body: { name: string; cwd: string }): Promise { + await clients.settings.resetAgentDisabled(body); }, async listHooks() { return clients.settings.listHooks({ cwd: cwd() }); }, - async setHookDisabled(name: string, disabled: boolean): Promise { - await clients.settings.setHookDisabled({ cwd: cwd(), name, disabled }); + async setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise { + await clients.settings.setHookDisabled({ cwd: cwd(), name: body.name, disabled: body.disabled }); + }, + + async resetHookDisabled(body: { name: string; cwd: string }): Promise { + await clients.settings.resetHookDisabled(body); }, async createHook(hook: any): Promise { diff --git a/packages/codingcode/src/client/direct/settings.ts b/packages/codingcode/src/client/direct/settings.ts index 6d0123b..be89a05 100644 --- a/packages/codingcode/src/client/direct/settings.ts +++ b/packages/codingcode/src/client/direct/settings.ts @@ -6,7 +6,7 @@ import { getGlobalPermissionMode, setGlobalPermissionMode } from '../../approval import type { PermissionMode } from '../../approval/types.js'; import type { AgentProfile } from '../../subagent/registry.js'; import type { UserHookConfig } from '../../hooks/config.js'; -import { loadMcpConfig, writeMcpConfig } from '../../mcp/config.js'; +import { loadMcpConfig, writeMcpConfig, resolveMcpDisabled, setGlobalMcpDisabledState, setProjectMcpDisabledState, resetProjectMcpDisabledState } from '../../mcp/config.js'; import { loadAgentProfiles, writeAgentProfile, @@ -15,12 +15,18 @@ import { } from '../../subagent/loader.js'; import { EXPLORE_PROFILE, - isAgentDisabledState, - setAgentDisabledState, - getSubagentEnabledState, setSubagentEnabledState, + resolveSubagentEnabled, + getProjectSubagentEnabledState, + setProjectSubagentEnabledState, + resetProjectSubagentEnabledState, + setGlobalAgentDisabledState, + setProjectAgentDisabledState, + resetProjectAgentDisabledState, + resolveAgentDisabled, + getProjectAgentDisabledState, } from '../../subagent/registry.js'; -import { loadHookConfigs, writeHookConfigs } from '../../hooks/config.js'; +import { loadHookConfigs, writeHookConfigs, resolveHookDisabled, setGlobalHookDisabledState, setProjectHookDisabledState, resetProjectHookDisabledState } from '../../hooks/config.js'; import { setHookRuntimeEnabled } from '../../hooks/executor.js'; import { getMemoryConfig, @@ -44,25 +50,29 @@ export interface SettingsClient { addMemoryExtraType(type: { name: string; description: string }): Promise; updateMemoryExtraType(name: string, type: { name: string; description: string }): Promise; deleteMemoryExtraType(name: string): Promise; - getSubagentEnabled(): Promise; - setSubagentEnabled(enabled: boolean): Promise; + getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; + setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; + resetSubagentEnabled(body: { cwd: string }): Promise; getMcpStatus(): Promise; - setMcpDisabled(name: string, disabled: boolean): Promise; + setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; + resetMcpDisabled(body: { name: string; cwd: string }): Promise; createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise; deleteMcpServer(input: { cwd: string; name: string }): Promise; listSkills(): Promise>; - toggleSkill(name: string, enabled: boolean): Promise; + toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; listAgents(input: { cwd: string }): Promise; createAgent(input: { cwd: string; profile: AgentProfile }): Promise; updateAgent(input: { cwd: string; name: string; profile: AgentProfile }): Promise; deleteAgent(input: { cwd: string; name: string }): Promise; - setAgentDisabled(name: string, disabled: boolean): Promise; + setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; + resetAgentDisabled(body: { name: string; cwd: string }): Promise; listHooks(input: { cwd: string }): Promise; createHook(input: { cwd: string; hook: UserHookConfig }): Promise; updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise; deleteHook(input: { cwd: string; name: string }): Promise; setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise; + resetHookDisabled(body: { name: string; cwd: string }): Promise; getGlobalPermissionMode(): Promise; setGlobalPermissionMode(mode: PermissionMode): Promise; } @@ -103,18 +113,27 @@ function agentsList(cwd: string): Array<{ maxSteps?: number; model?: string; disabled: boolean; + source: 'builtin' | 'global' | 'project'; + hasProjectOverride?: boolean; + projectDisabled?: boolean; }> { const custom = loadAgentProfiles(cwd); - return [EXPLORE_PROFILE, ...custom].map((a) => ({ - name: a.name, - description: a.description, - tools: a.tools, - mcpServers: a.mcpServers, - readonly: a.readonly, - maxSteps: a.maxSteps, - model: a.model, - disabled: isAgentDisabledState(a.name), - })); + return [EXPLORE_PROFILE, ...custom].map((a) => { + const projectVal = getProjectAgentDisabledState(cwd, a.name); + return { + name: a.name, + description: a.description, + tools: a.tools, + mcpServers: a.mcpServers, + readonly: a.readonly, + maxSteps: a.maxSteps, + model: a.model, + disabled: resolveAgentDisabled(cwd, a.name), + source: a.name === EXPLORE_PROFILE.name ? ('builtin' as const) : ('project' as const), + hasProjectOverride: projectVal !== undefined, + projectDisabled: projectVal, + }; + }); } function agentsCreate(cwd: string, profile: AgentProfile): void { @@ -206,12 +225,24 @@ export function createDirectSettingsClient( _deleteMemoryExtraType(name); }, - async getSubagentEnabled() { - return getSubagentEnabledState(); + async getSubagentEnabled({ cwd }) { + const projectVal = getProjectSubagentEnabledState(cwd); + return { + enabled: resolveSubagentEnabled(cwd), + source: projectVal !== undefined ? 'project' : 'global', + }; }, - async setSubagentEnabled(enabled) { - setSubagentEnabledState(enabled); + async setSubagentEnabled({ enabled, cwd }) { + if (!cwd || cwd === '' || cwd === 'global') { + setSubagentEnabledState(enabled); + } else { + setProjectSubagentEnabledState(cwd, enabled); + } + }, + + async resetSubagentEnabled({ cwd }) { + resetProjectSubagentEnabledState(cwd); }, async getMcpStatus() { @@ -223,17 +254,26 @@ export function createDirectSettingsClient( ); }, - async setMcpDisabled(name, disabled) { + async setMcpDisabled({ name, disabled, cwd }) { + if (!cwd || cwd === '' || cwd === 'global') { + setGlobalMcpDisabledState(name, disabled); + } else { + setProjectMcpDisabledState(cwd, name, disabled); + } await runWithLayer( Effect.gen(function* () { const mcp = yield* McpService; return yield* disabled - ? mcp.disable(process.cwd(), name) - : mcp.enable(process.cwd(), name); + ? mcp.disable(cwd || process.cwd(), name) + : mcp.enable(cwd || process.cwd(), name); }) ); }, + async resetMcpDisabled({ name, cwd }) { + resetProjectMcpDisabledState(cwd, name); + }, + async createMcpServer({ cwd, server }) { mcpCreateServer(cwd, server); }, @@ -255,12 +295,12 @@ export function createDirectSettingsClient( ); }, - async toggleSkill(name, enabled) { + async toggleSkill({ name, enabled, cwd }) { await runWithLayer( Effect.gen(function* () { const skill = yield* SkillService; - const cwd = process.cwd(); - return yield* enabled ? skill.enableSkill(cwd, name) : skill.disableSkill(cwd, name); + const skillCwd = cwd || process.cwd(); + return yield* enabled ? skill.enableSkill(skillCwd, name) : skill.disableSkill(skillCwd, name); }) ); }, @@ -281,8 +321,16 @@ export function createDirectSettingsClient( deleteAgentProfile(cwd, name); }, - async setAgentDisabled(name, disabled) { - setAgentDisabledState(name, disabled); + async setAgentDisabled({ name, disabled, cwd }) { + if (!cwd || cwd === '' || cwd === 'global') { + setGlobalAgentDisabledState(name, disabled); + } else { + setProjectAgentDisabledState(cwd, name, disabled); + } + }, + + async resetAgentDisabled({ name, cwd }) { + resetProjectAgentDisabledState(cwd, name); }, async listHooks({ cwd }) { @@ -302,9 +350,18 @@ export function createDirectSettingsClient( }, async setHookDisabled({ cwd, name, disabled }) { + if (!cwd || cwd === '' || cwd === 'global') { + setGlobalHookDisabledState(name, disabled); + } else { + setProjectHookDisabledState(cwd, name, disabled); + } hooksSetDisabled(cwd, name, disabled); }, + async resetHookDisabled({ name, cwd }) { + resetProjectHookDisabledState(cwd, name); + }, + async getGlobalPermissionMode() { return getGlobalPermissionMode(); }, diff --git a/packages/codingcode/src/client/http.ts b/packages/codingcode/src/client/http.ts index e3f697b..cbe38d2 100644 --- a/packages/codingcode/src/client/http.ts +++ b/packages/codingcode/src/client/http.ts @@ -275,31 +275,35 @@ export async function createHttpClient(serverUrl: string): Promise }, async getSubagentEnabled() { - return clients.settings.getSubagentEnabled(); + return clients.settings.getSubagentEnabled({ cwd: '' }); }, - async setSubagentEnabled(enabled: boolean) { - await clients.settings.setSubagentEnabled(enabled); + async setSubagentEnabled(body: { enabled: boolean; cwd: string }) { + await clients.settings.setSubagentEnabled(body); + }, + + async resetSubagentEnabled(body: { cwd: string }) { + await clients.settings.resetSubagentEnabled(body); }, async getMcpStatus() { return clients.settings.getMcpStatus(); }, - async disableMcp(name: string) { - await clients.settings.setMcpDisabled(name, true); + async setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }) { + await clients.settings.setMcpDisabled(body); }, - async enableMcp(name: string) { - await clients.settings.setMcpDisabled(name, false); + async resetMcpDisabled(body: { name: string; cwd: string }) { + await clients.settings.resetMcpDisabled(body); }, async listSkills() { return clients.settings.listSkills(); }, - async toggleSkill(name: string, enabled: boolean) { - await clients.settings.toggleSkill(name, enabled); + async toggleSkill(body: { name: string; enabled: boolean; cwd: string }) { + await clients.settings.toggleSkill(body); }, async createMcpServer(server: any) { @@ -330,16 +334,24 @@ export async function createHttpClient(serverUrl: string): Promise await clients.settings.deleteAgent({ cwd: '', name }); }, - async setAgentDisabled(name: string, disabled: boolean) { - await clients.settings.setAgentDisabled(name, disabled); + async setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }) { + await clients.settings.setAgentDisabled(body); + }, + + async resetAgentDisabled(body: { name: string; cwd: string }) { + await clients.settings.resetAgentDisabled(body); }, async listHooks() { return clients.settings.listHooks({ cwd: '' }); }, - async setHookDisabled(name: string, disabled: boolean) { - await clients.settings.setHookDisabled({ cwd: '', name, disabled }); + async setHookDisabled(body: { name: string; disabled: boolean; cwd: string }) { + await clients.settings.setHookDisabled({ cwd: '', name: body.name, disabled: body.disabled }); + }, + + async resetHookDisabled(body: { name: string; cwd: string }) { + await clients.settings.resetHookDisabled(body); }, async createHook(hook: any) { diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index 983fa07..ce1d1f7 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -15,25 +15,29 @@ export interface SettingsClient { addMemoryExtraType(type: { name: string; description: string }): Promise; updateMemoryExtraType(name: string, type: { name: string; description: string }): Promise; deleteMemoryExtraType(name: string): Promise; - getSubagentEnabled(): Promise; - setSubagentEnabled(enabled: boolean): Promise; + getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; + setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; + resetSubagentEnabled(body: { cwd: string }): Promise; getMcpStatus(): Promise; - setMcpDisabled(name: string, disabled: boolean): Promise; + setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; + resetMcpDisabled(body: { name: string; cwd: string }): Promise; createMcpServer(input: { cwd: string; server: McpServerConfig }): Promise; updateMcpServer(input: { cwd: string; name: string; server: McpServerConfig }): Promise; deleteMcpServer(input: { cwd: string; name: string }): Promise; listSkills(): Promise>; - toggleSkill(name: string, enabled: boolean): Promise; + toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; listAgents(input: { cwd: string }): Promise; createAgent(input: { cwd: string; profile: AgentProfile }): Promise; updateAgent(input: { cwd: string; name: string; profile: AgentProfile }): Promise; deleteAgent(input: { cwd: string; name: string }): Promise; - setAgentDisabled(name: string, disabled: boolean): Promise; + setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; + resetAgentDisabled(body: { name: string; cwd: string }): Promise; listHooks(input: { cwd: string }): Promise; createHook(input: { cwd: string; hook: UserHookConfig }): Promise; updateHook(input: { cwd: string; name: string; hook: UserHookConfig }): Promise; deleteHook(input: { cwd: string; name: string }): Promise; setHookDisabled(input: { cwd: string; name: string; disabled: boolean }): Promise; + resetHookDisabled(body: { name: string; cwd: string }): Promise; getGlobalPermissionMode(): Promise; setGlobalPermissionMode(mode: PermissionMode): Promise; } @@ -77,21 +81,28 @@ export function createHttpSettingsClient( await apiDelete(`/api/settings/memory/extra-type/${encodeURIComponent(name)}`); }, - async getSubagentEnabled() { - const data = await apiGet<{ enabled: boolean }>('/api/settings/subagent/enabled'); - return data.enabled; + async getSubagentEnabled({ cwd }) { + return apiGet<{ enabled: boolean; source: string }>(`/api/settings/subagent/enabled${qsCwd(cwd)}`); + }, + + async setSubagentEnabled({ enabled, cwd }) { + await apiPost(`/api/settings/subagent/enabled${qsCwd(cwd)}`, { enabled }); }, - async setSubagentEnabled(enabled) { - await apiPost('/api/settings/subagent/enabled', { enabled }); + async resetSubagentEnabled({ cwd }) { + await apiPost(`/api/settings/subagent/enabled/reset${qsCwd(cwd)}`, {}); }, async getMcpStatus() { return apiGet('/api/settings/mcp'); }, - async setMcpDisabled(name, disabled) { - await apiPost(`/api/settings/mcp/${encodeURIComponent(name)}/disabled`, { disabled }); + async setMcpDisabled({ name, disabled, cwd }) { + await apiPost(`/api/settings/mcp/${encodeURIComponent(name)}/disabled${qsCwd(cwd)}`, { disabled }); + }, + + async resetMcpDisabled({ name, cwd }) { + await apiPost(`/api/settings/mcp/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, {}); }, async createMcpServer({ cwd, server }) { @@ -110,8 +121,8 @@ export function createHttpSettingsClient( return apiGet('/api/settings/skills'); }, - async toggleSkill(name, enabled) { - await apiPost('/api/settings/skills', { name, enabled }); + async toggleSkill({ name, enabled, cwd }) { + await apiPost(`/api/settings/skills${qsCwd(cwd)}`, { name, enabled }); }, async listAgents({ cwd }) { @@ -130,8 +141,12 @@ export function createHttpSettingsClient( await apiDelete(`/api/settings/agents/${encodeURIComponent(name)}${qsCwd(cwd)}`); }, - async setAgentDisabled(name, disabled) { - await apiPost(`/api/settings/agents/${encodeURIComponent(name)}/disabled`, { disabled }); + async setAgentDisabled({ name, disabled, cwd }) { + await apiPost(`/api/settings/agents/${encodeURIComponent(name)}/disabled${qsCwd(cwd)}`, { disabled }); + }, + + async resetAgentDisabled({ name, cwd }) { + await apiPost(`/api/settings/agents/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, {}); }, async listHooks({ cwd }) { @@ -156,6 +171,10 @@ export function createHttpSettingsClient( }); }, + async resetHookDisabled({ name, cwd }) { + await apiPost(`/api/settings/hooks/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, {}); + }, + async getGlobalPermissionMode() { const data = await apiGet<{ mode: PermissionMode }>('/api/agent/permission-mode'); return data.mode; diff --git a/packages/codingcode/src/client/types.ts b/packages/codingcode/src/client/types.ts index 1a6091e..7f7050a 100644 --- a/packages/codingcode/src/client/types.ts +++ b/packages/codingcode/src/client/types.ts @@ -68,16 +68,17 @@ export interface AgentClient { addExtraType(type: { name: string; description: string }): Promise; updateExtraType(name: string, type: { name: string; description: string }): Promise; deleteExtraType(name: string): Promise; - getSubagentEnabled(): Promise; - setSubagentEnabled(enabled: boolean): Promise; + getSubagentEnabled(query: { cwd: string }): Promise<{ enabled: boolean; source: string }>; + setSubagentEnabled(body: { enabled: boolean; cwd: string }): Promise; + resetSubagentEnabled(body: { cwd: string }): Promise; getMcpStatus(): Promise; createMcpServer(server: McpServerConfig): Promise; updateMcpServer(name: string, server: McpServerConfig): Promise; deleteMcpServer(name: string): Promise; - disableMcp(name: string): Promise; - enableMcp(name: string): Promise; + setMcpDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; + resetMcpDisabled(body: { name: string; cwd: string }): Promise; listSkills(): Promise>; - toggleSkill(name: string, enabled: boolean): Promise; + toggleSkill(body: { name: string; enabled: boolean; cwd: string }): Promise; listAgents(): Promise< Array<{ name: string; @@ -93,9 +94,11 @@ export interface AgentClient { createAgent(profile: AgentProfile): Promise; updateAgent(name: string, profile: AgentProfile): Promise; deleteAgent(name: string): Promise; - setAgentDisabled(name: string, disabled: boolean): Promise; + setAgentDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; + resetAgentDisabled(body: { name: string; cwd: string }): Promise; listHooks(): Promise; - setHookDisabled(name: string, disabled: boolean): Promise; + setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise; + resetHookDisabled(body: { name: string; cwd: string }): Promise; createHook(hook: UserHookConfig): Promise; updateHook(name: string, hook: UserHookConfig): Promise; deleteHook(name: string): Promise; diff --git a/packages/codingcode/src/hooks/config.ts b/packages/codingcode/src/hooks/config.ts index 84009ad..9c585e0 100644 --- a/packages/codingcode/src/hooks/config.ts +++ b/packages/codingcode/src/hooks/config.ts @@ -1,5 +1,6 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import { join } from 'path'; +import { homedir } from 'os'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import type { HookPoint } from './registry.js'; @@ -15,6 +16,17 @@ export interface UserHookConfig { enabled: boolean; } +function getGlobalConfigDir(): string { + return join(homedir(), '.codingcode'); +} + +function mergeByName(global: T[], project: T[]): T[] { + const map = new Map(); + for (const item of global) map.set(item.name, item); + for (const item of project) map.set(item.name, item); + return Array.from(map.values()); +} + export function loadHookConfigs(projectRoot: string): UserHookConfig[] { const paths = [ join(projectRoot, '.codingcode', 'hooks.yaml'), @@ -40,3 +52,116 @@ export function writeHookConfigs(projectRoot: string, hooks: UserHookConfig[]): existing.hooks = hooks; writeFileSync(p, stringifyYaml(existing), 'utf8'); } + +export function loadGlobalHookConfigs(): UserHookConfig[] { + const paths = [ + join(getGlobalConfigDir(), 'hooks.yaml'), + join(getGlobalConfigDir(), 'hooks.yml'), + ]; + for (const p of paths) { + if (existsSync(p)) { + const raw = readFileSync(p, 'utf8'); + const parsed = parseYaml(raw) as { hooks?: UserHookConfig[] }; + return parsed.hooks ?? []; + } + } + return []; +} + +export function writeGlobalHookConfigs(hooks: UserHookConfig[]): void { + const dir = getGlobalConfigDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = join(dir, 'hooks.yaml'); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + existing.hooks = hooks; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +export function resolveHookConfigs(projectRoot: string): UserHookConfig[] { + const globalHooks = loadGlobalHookConfigs(); + const projectHooks = loadHookConfigs(projectRoot); + return mergeByName(globalHooks, projectHooks); +} + +// ---- 全局级 Hook disabled 状态:持久化到 ~/.codingcode/config.yaml ---- + +export function getGlobalHookDisabledState(hookName: string): boolean { + try { + const p = join(getGlobalConfigDir(), 'config.yaml'); + if (!existsSync(p)) return false; + const raw = readFileSync(p, 'utf8'); + const config = parseYaml(raw) as any; + const disabled = config.hooks?.disabledHooks as Record; + return disabled?.[hookName] ?? false; + } catch { + return false; + } +} + +export function setGlobalHookDisabledState(hookName: string, disabled: boolean): void { + const dir = getGlobalConfigDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = join(dir, 'config.yaml'); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + const hooks = (existing.hooks as Record) ?? {}; + const disabledHooks = (hooks.disabledHooks as Record) ?? {}; + disabledHooks[hookName] = disabled; + hooks.disabledHooks = disabledHooks; + existing.hooks = hooks; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +// ---- 项目级 Hook disabled 状态:持久化到 .codingcode/config.yaml ---- + +export function getProjectHookDisabledState(projectRoot: string, hookName: string): boolean | undefined { + const p = join(projectRoot, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return undefined; + try { + const raw = readFileSync(p, 'utf8'); + const config = parseYaml(raw) as any; + const disabled = config.hooks?.disabledHooks as Record; + return disabled?.[hookName]; + } catch { + return undefined; + } +} + +export function setProjectHookDisabledState(projectRoot: string, hookName: string, disabled: boolean): void { + const dir = join(projectRoot, '.codingcode'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = join(dir, 'config.yaml'); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + const hooks = (existing.hooks as Record) ?? {}; + const disabledHooks = (hooks.disabledHooks as Record) ?? {}; + disabledHooks[hookName] = disabled; + hooks.disabledHooks = disabledHooks; + existing.hooks = hooks; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +export function resetProjectHookDisabledState(projectRoot: string, hookName: string): void { + const p = join(projectRoot, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return; + const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record; + const hooks = (existing.hooks as Record) ?? {}; + const disabledHooks = hooks.disabledHooks as Record; + if (disabledHooks) { + delete disabledHooks[hookName]; + hooks.disabledHooks = disabledHooks; + } + existing.hooks = hooks; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +// 解析最终生效的 Hook disabled 状态:项目级 > 全局级 +export function resolveHookDisabled(projectRoot: string, hookName: string): boolean { + const projectVal = getProjectHookDisabledState(projectRoot, hookName); + if (projectVal !== undefined) return projectVal; + return getGlobalHookDisabledState(hookName); +} diff --git a/packages/codingcode/src/hooks/registry.ts b/packages/codingcode/src/hooks/registry.ts index fded3f8..1ff017a 100644 --- a/packages/codingcode/src/hooks/registry.ts +++ b/packages/codingcode/src/hooks/registry.ts @@ -1,5 +1,5 @@ import { Effect } from 'effect'; -import { loadHookConfigs } from './config'; +import { resolveHookConfigs, resolveHookDisabled } from './config'; import { executeHookCommand, executeDecisionHookCommand, isHookRuntimeEnabled } from './executor'; import { createLogger } from '@codingcode/infra'; @@ -110,7 +110,7 @@ export class HookService extends Effect.Service()('HookService', { function isHookDisabled(name: string, projectPath?: string, sessionId?: string): boolean { if (sessionId && disabledHooksBySession.get(sessionId)?.has(name)) return true; - if (projectPath && disabledHooksByProject.get(projectPath)?.has(name)) return true; + if (projectPath && resolveHookDisabled(projectPath, name)) return true; return false; } @@ -210,8 +210,8 @@ export class HookService extends Effect.Service()('HookService', { } hooksByProject.delete(projectPath); const projectMap = new Map(); - for (const hc of loadHookConfigs(projectPath)) { - if (!hc.enabled) continue; + for (const hc of resolveHookConfigs(projectPath)) { + if (resolveHookDisabled(projectPath, hc.name)) continue; const hookName = hc.name; const entry: HandlerEntry = { id: `${hc.type === 'observer' ? 'obs' : 'dec'}-${++entryCounter}`, diff --git a/packages/codingcode/src/mcp/config.ts b/packages/codingcode/src/mcp/config.ts index b0811d0..f640abf 100644 --- a/packages/codingcode/src/mcp/config.ts +++ b/packages/codingcode/src/mcp/config.ts @@ -1,5 +1,6 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import { join } from 'path'; +import { homedir } from 'os'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import type { McpServerConfig } from './types'; @@ -16,6 +17,17 @@ function resolveEnvVars(value: unknown): unknown { return value; } +function getGlobalConfigDir(): string { + return join(homedir(), '.codingcode'); +} + +function mergeByName(global: T[], project: T[]): T[] { + const map = new Map(); + for (const item of global) map.set(item.name, item); + for (const item of project) map.set(item.name, item); + return Array.from(map.values()); +} + export function loadMcpConfig(projectRoot: string): McpServerConfig[] { const paths = [ join(projectRoot, '.codingcode', 'mcp.yaml'), @@ -41,3 +53,116 @@ export function writeMcpConfig(projectRoot: string, servers: McpServerConfig[]): existing.servers = servers; writeFileSync(p, stringifyYaml(existing), 'utf8'); } + +export function loadGlobalMcpConfig(): McpServerConfig[] { + const paths = [ + join(getGlobalConfigDir(), 'mcp.yaml'), + join(getGlobalConfigDir(), 'mcp.yml'), + ]; + for (const p of paths) { + if (existsSync(p)) { + const raw = readFileSync(p, 'utf8'); + const parsed = parseYaml(raw) as { servers?: McpServerConfig[] }; + return (parsed.servers ?? []).map((s) => resolveEnvVars(s) as McpServerConfig); + } + } + return []; +} + +export function writeGlobalMcpConfig(servers: McpServerConfig[]): void { + const dir = getGlobalConfigDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = join(dir, 'mcp.yaml'); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + existing.servers = servers; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +export function resolveMcpConfig(projectRoot: string): McpServerConfig[] { + const globalServers = loadGlobalMcpConfig(); + const projectServers = loadMcpConfig(projectRoot); + return mergeByName(globalServers, projectServers); +} + +// ---- 全局级 MCP disabled 状态:持久化到 ~/.codingcode/config.yaml ---- + +export function getGlobalMcpDisabledState(serverName: string): boolean { + try { + const p = join(getGlobalConfigDir(), 'config.yaml'); + if (!existsSync(p)) return false; + const raw = readFileSync(p, 'utf8'); + const config = parseYaml(raw) as any; + const disabled = config.mcp?.disabledServers as Record; + return disabled?.[serverName] ?? false; + } catch { + return false; + } +} + +export function setGlobalMcpDisabledState(serverName: string, disabled: boolean): void { + const dir = getGlobalConfigDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = join(dir, 'config.yaml'); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + const mcp = (existing.mcp as Record) ?? {}; + const disabledServers = (mcp.disabledServers as Record) ?? {}; + disabledServers[serverName] = disabled; + mcp.disabledServers = disabledServers; + existing.mcp = mcp; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +// ---- 项目级 MCP disabled 状态:持久化到 .codingcode/config.yaml ---- + +export function getProjectMcpDisabledState(projectRoot: string, serverName: string): boolean | undefined { + const p = join(projectRoot, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return undefined; + try { + const raw = readFileSync(p, 'utf8'); + const config = parseYaml(raw) as any; + const disabled = config.mcp?.disabledServers as Record; + return disabled?.[serverName]; + } catch { + return undefined; + } +} + +export function setProjectMcpDisabledState(projectRoot: string, serverName: string, disabled: boolean): void { + const dir = join(projectRoot, '.codingcode'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = join(dir, 'config.yaml'); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + const mcp = (existing.mcp as Record) ?? {}; + const disabledServers = (mcp.disabledServers as Record) ?? {}; + disabledServers[serverName] = disabled; + mcp.disabledServers = disabledServers; + existing.mcp = mcp; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +export function resetProjectMcpDisabledState(projectRoot: string, serverName: string): void { + const p = join(projectRoot, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return; + const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record; + const mcp = (existing.mcp as Record) ?? {}; + const disabledServers = mcp.disabledServers as Record; + if (disabledServers) { + delete disabledServers[serverName]; + mcp.disabledServers = disabledServers; + } + existing.mcp = mcp; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +// 解析最终生效的 MCP disabled 状态:项目级 > 全局级 +export function resolveMcpDisabled(projectRoot: string, serverName: string): boolean { + const projectVal = getProjectMcpDisabledState(projectRoot, serverName); + if (projectVal !== undefined) return projectVal; + return getGlobalMcpDisabledState(serverName); +} diff --git a/packages/codingcode/src/mcp/index.ts b/packages/codingcode/src/mcp/index.ts index 234e072..c600f87 100644 --- a/packages/codingcode/src/mcp/index.ts +++ b/packages/codingcode/src/mcp/index.ts @@ -1,6 +1,6 @@ import { Effect } from 'effect'; import { z } from 'zod'; -import { loadMcpConfig } from './config.js'; +import { resolveMcpConfig, resolveMcpDisabled } from './config.js'; import { McpClient, McpError } from './client.js'; import type { McpServerConfig, McpStatus } from './types.js'; import type { ToolDefinition, ToolExecCtx } from '../tools/types.js'; @@ -42,7 +42,7 @@ export class McpService extends Effect.Service()('Mcp', { function getConfig(projectPath: string): McpServerConfig[] { const cached = configCache.get(projectPath); if (cached) return cached; - const configs = loadMcpConfig(projectPath); + const configs = resolveMcpConfig(projectPath); configCache.set(projectPath, configs); return configs; } @@ -57,7 +57,7 @@ export class McpService extends Effect.Service()('Mcp', { } function isDisabled(projectPath: string, serverName: string): boolean { - return disabledMcpByProject.get(projectPath)?.has(serverName) ?? false; + return resolveMcpDisabled(projectPath, serverName); } function doConnect( @@ -186,7 +186,7 @@ export class McpService extends Effect.Service()('Mcp', { return { syncConnections: (projectPath: string): Effect.Effect => Effect.gen(function* () { - const configs = loadMcpConfig(projectPath); + const configs = resolveMcpConfig(projectPath); configCache.set(projectPath, configs); const configNames = new Set(configs.map((c) => c.name)); diff --git a/packages/codingcode/src/runtime/project-runtime.ts b/packages/codingcode/src/runtime/project-runtime.ts index 1b72cb3..960a26a 100644 --- a/packages/codingcode/src/runtime/project-runtime.ts +++ b/packages/codingcode/src/runtime/project-runtime.ts @@ -51,12 +51,10 @@ export class ProjectRuntimeService extends Effect.Service cachedSubagentProfiles.set(norm, buildProfiles(norm)); }), - resolveMainAgentProfile: (projectPath: string, sessionId: string): AgentProfile => { + resolveMainAgentProfile: (projectPath: string, sessionId: string): AgentProfile | undefined => { const sessionOverride = sessionAgentProfiles.get(sessionId); if (sessionOverride) return sessionOverride; - const fromFile = agentLoader.loadMainAgentProfile(projectPath); - if (fromFile) return fromFile; - return agentLoader.DEFAULT_MAIN_PROFILE; + return agentLoader.loadMainAgentProfile(projectPath); }, resolveSubagentProfile: (projectPath: string, name: string): AgentProfile | undefined => { @@ -72,9 +70,9 @@ export class ProjectRuntimeService extends Effect.Service return cached ? [...cached] : buildProfiles(normalized); }, - getToolPolicy: (profile: AgentProfile): ToolVisibilityPolicy => ({ - allowedTools: profile.tools ? new Set(profile.tools) : undefined, - allowedMcpServers: profile.mcpServers ? new Set(profile.mcpServers) : undefined, + getToolPolicy: (profile: AgentProfile | undefined): ToolVisibilityPolicy => ({ + allowedTools: profile?.tools ? new Set(profile.tools) : undefined, + allowedMcpServers: profile?.mcpServers ? new Set(profile.mcpServers) : undefined, allowToolSearch: true, allowDeferredTools: false, }), diff --git a/packages/codingcode/src/server/index.ts b/packages/codingcode/src/server/index.ts index c57d1dd..0a8d541 100644 --- a/packages/codingcode/src/server/index.ts +++ b/packages/codingcode/src/server/index.ts @@ -21,6 +21,7 @@ export async function createServer(): Promise { if (err instanceof AlreadyExistsError) { return c.json({ error: { code: 'ALREADY_EXISTS', message: err.message } }, 409); } + console.error('[500 INTERNAL_ERROR]', err); return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Internal server error' } }, 500); }); diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index 44b9f12..e862e7c 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -1,28 +1,66 @@ import { Hono } from 'hono'; import { Effect } from 'effect'; -import { McpService } from '../../mcp/index.js'; import { SkillService } from '../../skills/index.js'; import { resolveWorkspaceCwd } from '../../core/workspace.js'; import { AlreadyExistsError, NotFoundError } from '../../core/error.js'; import type { McpServerConfig } from '../../mcp/types.js'; import type { AgentProfile } from '../../subagent/registry.js'; import type { UserHookConfig } from '../../hooks/config.js'; -import { loadMcpConfig, writeMcpConfig } from '../../mcp/config.js'; +import { + loadMcpConfig, + writeMcpConfig, + loadGlobalMcpConfig, + writeGlobalMcpConfig, + resolveMcpConfig, + resolveMcpDisabled, + getGlobalMcpDisabledState, + setGlobalMcpDisabledState, + setProjectMcpDisabledState, + resetProjectMcpDisabledState, +} from '../../mcp/config.js'; import { loadAgentProfiles, writeAgentProfile, updateAgentProfile, deleteAgentProfile, + loadGlobalAgentProfiles, + writeGlobalAgentProfile, + updateGlobalAgentProfile, + deleteGlobalAgentProfile, } from '../../subagent/loader.js'; import { EXPLORE_PROFILE, - isAgentDisabledState, - setAgentDisabledState, + resolveSubagentEnabled, + getProjectSubagentEnabledState, + setProjectSubagentEnabledState, + resetProjectSubagentEnabledState, + getGlobalAgentDisabledState, + setGlobalAgentDisabledState, + getProjectAgentDisabledState, + setProjectAgentDisabledState, + resetProjectAgentDisabledState, + resolveAgentDisabled, getSubagentEnabledState, setSubagentEnabledState, } from '../../subagent/registry.js'; -import { loadHookConfigs, writeHookConfigs } from '../../hooks/config.js'; +import { + loadHookConfigs, + writeHookConfigs, + loadGlobalHookConfigs, + writeGlobalHookConfigs, + resolveHookConfigs, + resolveHookDisabled, + setGlobalHookDisabledState, + setProjectHookDisabledState, + resetProjectHookDisabledState, +} from '../../hooks/config.js'; import { setHookRuntimeEnabled } from '../../hooks/executor.js'; +import { + setGlobalSkillDisabledState, + setProjectSkillDisabledState, + discoverGlobalSkillDirs, + discoverProjectSkillDirs, +} from '../../skills/config.js'; import { getMemoryConfig, getAllTypesWithStatus, @@ -36,6 +74,12 @@ import { runWithLayer, errorResponse } from '../util.js'; export const settingsRouter = new Hono(); +// ---- Helpers for global vs project ---- + +function isGlobalCwd(cwd: string | undefined): boolean { + return !cwd || cwd === '' || cwd === 'global'; +} + // ---- Helpers for CRUD with validation ---- function mcpCreateServer(cwd: string, server: McpServerConfig): void { @@ -72,18 +116,83 @@ function agentsList(cwd: string): Array<{ maxSteps?: number; model?: string; disabled: boolean; + source: 'builtin' | 'global' | 'project'; + hasProjectOverride?: boolean; + projectDisabled?: boolean; }> { - const custom = loadAgentProfiles(cwd); - return [EXPLORE_PROFILE, ...custom].map((a) => ({ - name: a.name, - description: a.description, - tools: a.tools, - mcpServers: a.mcpServers, - readonly: a.readonly, - maxSteps: a.maxSteps, - model: a.model, - disabled: isAgentDisabledState(a.name), - })); + const globalCustom = loadGlobalAgentProfiles(); + const projectCustom = loadAgentProfiles(cwd); + const globalNames = new Set(globalCustom.map((a) => a.name)); + const projectNames = new Set(projectCustom.map((a) => a.name)); + + const result: Array<{ + name: string; + description: string; + tools?: string[]; + mcpServers?: string[]; + readonly?: boolean; + maxSteps?: number; + model?: string; + disabled: boolean; + source: 'builtin' | 'global' | 'project'; + hasProjectOverride?: boolean; + projectDisabled?: boolean; + }> = []; + + // builtin: EXPLORE_PROFILE + const exploreProjectVal = getProjectAgentDisabledState(cwd, EXPLORE_PROFILE.name); + result.push({ + name: EXPLORE_PROFILE.name, + description: EXPLORE_PROFILE.description, + tools: EXPLORE_PROFILE.tools, + mcpServers: EXPLORE_PROFILE.mcpServers, + readonly: EXPLORE_PROFILE.readonly, + maxSteps: EXPLORE_PROFILE.maxSteps, + model: EXPLORE_PROFILE.model, + disabled: resolveAgentDisabled(cwd, EXPLORE_PROFILE.name), + source: 'builtin', + hasProjectOverride: exploreProjectVal !== undefined, + projectDisabled: exploreProjectVal, + }); + + // global agents (not overridden by project) + for (const a of globalCustom) { + if (projectNames.has(a.name)) continue; + const projectVal = getProjectAgentDisabledState(cwd, a.name); + result.push({ + name: a.name, + description: a.description, + tools: a.tools, + mcpServers: a.mcpServers, + readonly: a.readonly, + maxSteps: a.maxSteps, + model: a.model, + disabled: resolveAgentDisabled(cwd, a.name), + source: 'global', + hasProjectOverride: projectVal !== undefined, + projectDisabled: projectVal, + }); + } + + // project agents + for (const a of projectCustom) { + const projectVal = getProjectAgentDisabledState(cwd, a.name); + result.push({ + name: a.name, + description: a.description, + tools: a.tools, + mcpServers: a.mcpServers, + readonly: a.readonly, + maxSteps: a.maxSteps, + model: a.model, + disabled: resolveAgentDisabled(cwd, a.name), + source: globalNames.has(a.name) ? 'global' : 'project', + hasProjectOverride: projectVal !== undefined, + projectDisabled: projectVal, + }); + } + + return result; } function agentsCreate(cwd: string, profile: AgentProfile): void { @@ -128,16 +237,6 @@ function hooksDelete(cwd: string, name: string): void { writeHookConfigs(cwd, hooks); } -function hooksSetDisabled(cwd: string, name: string, disabled: boolean): void { - setHookRuntimeEnabled(name, !disabled); - const hooks = loadHookConfigs(cwd); - const hook = hooks.find((h) => h.name === name); - if (hook) { - hook.enabled = !disabled; - writeHookConfigs(cwd, hooks); - } -} - // ---- Memory ---- settingsRouter.get('/memory/config', (c) => { const cfg = getMemoryConfig(); @@ -193,15 +292,38 @@ settingsRouter.delete('/memory/extra-type/:name', async (c) => { // ---- Agents ---- settingsRouter.get('/agents', (c) => { - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + const custom = loadGlobalAgentProfiles(); + return c.json([EXPLORE_PROFILE, ...custom].map((a) => ({ + name: a.name, + description: a.description, + tools: a.tools, + mcpServers: a.mcpServers, + readonly: a.readonly, + maxSteps: a.maxSteps, + model: a.model, + disabled: getGlobalAgentDisabledState(a.name), + source: a.name === EXPLORE_PROFILE.name ? 'builtin' : 'global', + }))); + } + const cwd = resolveWorkspaceCwd(rawCwd); return c.json(agentsList(cwd)); }); settingsRouter.post('/agents', async (c) => { - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); + const rawCwd = c.req.query('cwd'); const body = (await c.req.json()) as AgentProfile; try { - agentsCreate(cwd, body); + if (isGlobalCwd(rawCwd)) { + const existing = loadGlobalAgentProfiles(); + if (existing.some((a) => a.name === body.name)) { + throw new AlreadyExistsError(`Agent '${body.name}' already exists`); + } + writeGlobalAgentProfile(body); + } else { + agentsCreate(resolveWorkspaceCwd(rawCwd), body); + } return c.json({ ok: true }); } catch (e) { if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); @@ -211,10 +333,14 @@ settingsRouter.post('/agents', async (c) => { settingsRouter.put('/agents/:name', async (c) => { const name = c.req.param('name'); - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); + const rawCwd = c.req.query('cwd'); const body = (await c.req.json()) as AgentProfile; try { - agentsUpdate(cwd, name, body); + if (isGlobalCwd(rawCwd)) { + updateGlobalAgentProfile(name, body); + } else { + agentsUpdate(resolveWorkspaceCwd(rawCwd), name, body); + } return c.json({ ok: true }); } catch (e) { if (e instanceof NotFoundError) return c.json({ error: e.message }, 404); @@ -225,29 +351,76 @@ settingsRouter.put('/agents/:name', async (c) => { settingsRouter.delete('/agents/:name', async (c) => { const name = c.req.param('name'); - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); - deleteAgentProfile(cwd, name); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + deleteGlobalAgentProfile(name); + } else { + deleteAgentProfile(resolveWorkspaceCwd(rawCwd), name); + } return c.json({ ok: true }); }); settingsRouter.post('/agents/:name/disabled', async (c) => { const name = c.req.param('name'); + const rawCwd = c.req.query('cwd'); const body = (await c.req.json()) as { disabled: boolean }; - setAgentDisabledState(name, body.disabled); + if (isGlobalCwd(rawCwd)) { + setGlobalAgentDisabledState(name, body.disabled); + } else { + setProjectAgentDisabledState(resolveWorkspaceCwd(rawCwd), name, body.disabled); + } + return c.json({ ok: true }); +}); + +settingsRouter.post('/agents/:name/disabled/reset', async (c) => { + const name = c.req.param('name'); + const rawCwd = c.req.query('cwd'); + resetProjectAgentDisabledState(resolveWorkspaceCwd(rawCwd), name); return c.json({ ok: true }); }); // ---- Hooks ---- settingsRouter.get('/hooks', (c) => { - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); - return c.json(loadHookConfigs(cwd)); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + return c.json(loadGlobalHookConfigs().map((h) => ({ + ...h, + source: 'global' as const, + }))); + } + const cwd = resolveWorkspaceCwd(rawCwd); + const globalHooks = loadGlobalHookConfigs(); + const projectHooks = loadHookConfigs(cwd); + const globalNames = new Set(globalHooks.map((h) => h.name)); + const projectNames = new Set(projectHooks.map((h) => h.name)); + const merged = resolveHookConfigs(cwd); + return c.json(merged.map((h) => { + const isFromProject = projectNames.has(h.name); + const isFromGlobal = globalNames.has(h.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...h, + source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + hasProjectOverride, + disabled: resolveHookDisabled(cwd, h.name), + }; + })); }); settingsRouter.post('/hooks', async (c) => { - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); + const rawCwd = c.req.query('cwd'); const body = (await c.req.json()) as UserHookConfig; try { - hooksCreate(cwd, body); + if (isGlobalCwd(rawCwd)) { + const hooks = loadGlobalHookConfigs(); + if (hooks.some((h) => h.name === body.name)) { + throw new AlreadyExistsError(`Hook '${body.name}' already exists`); + } + hooks.push(body); + writeGlobalHookConfigs(hooks); + } else { + hooksCreate(resolveWorkspaceCwd(rawCwd), body); + } return c.json({ ok: true }); } catch (e) { if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); @@ -257,10 +430,21 @@ settingsRouter.post('/hooks', async (c) => { settingsRouter.put('/hooks/:name', async (c) => { const name = c.req.param('name'); - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); + const rawCwd = c.req.query('cwd'); const body = (await c.req.json()) as UserHookConfig; try { - hooksUpdate(cwd, name, body); + if (isGlobalCwd(rawCwd)) { + const hooks = loadGlobalHookConfigs(); + const idx = hooks.findIndex((h) => h.name === name); + if (idx === -1) throw new NotFoundError(`Hook '${name}' not found`); + if (body.name !== name && hooks.some((h) => h.name === body.name)) { + throw new AlreadyExistsError(`Hook '${body.name}' already exists`); + } + hooks[idx] = body; + writeGlobalHookConfigs(hooks); + } else { + hooksUpdate(resolveWorkspaceCwd(rawCwd), name, body); + } return c.json({ ok: true }); } catch (e) { if (e instanceof NotFoundError) return c.json({ error: e.message }, 404); @@ -271,39 +455,93 @@ settingsRouter.put('/hooks/:name', async (c) => { settingsRouter.delete('/hooks/:name', async (c) => { const name = c.req.param('name'); - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); - hooksDelete(cwd, name); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + const hooks = loadGlobalHookConfigs().filter((h) => h.name !== name); + writeGlobalHookConfigs(hooks); + } else { + hooksDelete(resolveWorkspaceCwd(rawCwd), name); + } return c.json({ ok: true }); }); settingsRouter.post('/hooks/:name/disabled', async (c) => { const name = c.req.param('name'); const body = (await c.req.json()) as { disabled: boolean }; - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); - hooksSetDisabled(cwd, name, body.disabled); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + setGlobalHookDisabledState(name, body.disabled); + setHookRuntimeEnabled(name, !body.disabled); + const hooks = loadGlobalHookConfigs(); + const hook = hooks.find((h) => h.name === name); + if (hook) { + hook.enabled = !body.disabled; + writeGlobalHookConfigs(hooks); + } + } else { + const cwd = resolveWorkspaceCwd(rawCwd); + setProjectHookDisabledState(cwd, name, body.disabled); + setHookRuntimeEnabled(name, !body.disabled); + const hooks = loadHookConfigs(cwd); + const hook = hooks.find((h) => h.name === name); + if (hook) { + hook.enabled = !body.disabled; + writeHookConfigs(cwd, hooks); + } + } + return c.json({ ok: true }); +}); + +settingsRouter.post('/hooks/:name/disabled/reset', async (c) => { + const name = c.req.param('name'); + const rawCwd = c.req.query('cwd'); + resetProjectHookDisabledState(resolveWorkspaceCwd(rawCwd), name); return c.json({ ok: true }); }); // ---- MCP ---- settingsRouter.get('/mcp', async (c) => { - const result = await runWithLayer( - Effect.gen(function* () { - const mcp = yield* McpService; - return yield* mcp.status(resolveWorkspaceCwd(c.req.query('cwd'))); - }) - ); - if (!result.ok) { - const { status, body } = errorResponse(result.error); - return c.json(body, status as any); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + return c.json(loadGlobalMcpConfig().map((s) => ({ + ...s, + disabled: getGlobalMcpDisabledState(s.name), + source: 'global' as const, + }))); } - return c.json(result.value); + const cwd = resolveWorkspaceCwd(rawCwd); + const globalServers = loadGlobalMcpConfig(); + const projectServers = loadMcpConfig(cwd); + const globalNames = new Set(globalServers.map((s) => s.name)); + const projectNames = new Set(projectServers.map((s) => s.name)); + const merged = resolveMcpConfig(cwd); + return c.json(merged.map((s) => { + const isFromProject = projectNames.has(s.name); + const isFromGlobal = globalNames.has(s.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...s, + disabled: resolveMcpDisabled(cwd, s.name), + source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + hasProjectOverride, + }; + })); }); settingsRouter.post('/mcp', async (c) => { - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); + const rawCwd = c.req.query('cwd'); const body = (await c.req.json()) as McpServerConfig; try { - mcpCreateServer(cwd, body); + if (isGlobalCwd(rawCwd)) { + const servers = loadGlobalMcpConfig(); + if (servers.some((s) => s.name === body.name)) { + throw new AlreadyExistsError(`MCP server '${body.name}' already exists`); + } + servers.push(body); + writeGlobalMcpConfig(servers); + } else { + mcpCreateServer(resolveWorkspaceCwd(rawCwd), body); + } return c.json({ ok: true }); } catch (e) { if (e instanceof AlreadyExistsError) return c.json({ error: e.message }, 409); @@ -313,10 +551,21 @@ settingsRouter.post('/mcp', async (c) => { settingsRouter.put('/mcp/:name', async (c) => { const name = c.req.param('name'); - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); + const rawCwd = c.req.query('cwd'); const body = (await c.req.json()) as McpServerConfig; try { - mcpUpdateServer(cwd, name, body); + if (isGlobalCwd(rawCwd)) { + const servers = loadGlobalMcpConfig(); + const idx = servers.findIndex((s) => s.name === name); + if (idx === -1) throw new NotFoundError(`MCP server '${name}' not found`); + if (body.name !== name && servers.some((s) => s.name === body.name)) { + throw new AlreadyExistsError(`MCP server '${body.name}' already exists`); + } + servers[idx] = body; + writeGlobalMcpConfig(servers); + } else { + mcpUpdateServer(resolveWorkspaceCwd(rawCwd), name, body); + } return c.json({ ok: true }); } catch (e) { if (e instanceof NotFoundError) return c.json({ error: e.message }, 404); @@ -327,69 +576,120 @@ settingsRouter.put('/mcp/:name', async (c) => { settingsRouter.delete('/mcp/:name', async (c) => { const name = c.req.param('name'); - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); - mcpDeleteServer(cwd, name); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + const servers = loadGlobalMcpConfig().filter((s) => s.name !== name); + writeGlobalMcpConfig(servers); + } else { + mcpDeleteServer(resolveWorkspaceCwd(rawCwd), name); + } return c.json({ ok: true }); }); settingsRouter.post('/mcp/:name/disabled', async (c) => { const name = c.req.param('name'); + const rawCwd = c.req.query('cwd'); const body = (await c.req.json()) as { disabled: boolean }; - const result = await runWithLayer( - Effect.gen(function* () { - const mcp = yield* McpService; - return yield* body.disabled - ? mcp.disable(resolveWorkspaceCwd(c.req.query('cwd')), name) - : mcp.enable(resolveWorkspaceCwd(c.req.query('cwd')), name); - }) - ); - if (!result.ok) { - const { status, body: resp } = errorResponse(result.error); - return c.json(resp, status as any); + if (isGlobalCwd(rawCwd)) { + setGlobalMcpDisabledState(name, body.disabled); + } else { + setProjectMcpDisabledState(resolveWorkspaceCwd(rawCwd), name, body.disabled); } return c.json({ ok: true }); }); +settingsRouter.post('/mcp/:name/disabled/reset', async (c) => { + const name = c.req.param('name'); + const rawCwd = c.req.query('cwd'); + resetProjectMcpDisabledState(resolveWorkspaceCwd(rawCwd), name); + return c.json({ ok: true }); +}); + // ---- Skills ---- settingsRouter.get('/skills', async (c) => { + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + const result = await runWithLayer( + Effect.gen(function* () { + const skill = yield* SkillService; + return yield* skill.listWithStatus(resolveWorkspaceCwd(rawCwd)); + }) + ); + if (!result.ok) { + const { status, body } = errorResponse(result.error); + return c.json(body, status as any); + } + return c.json(result.value.map((s) => ({ + ...s, + source: 'global' as const, + }))); + } + const cwd = resolveWorkspaceCwd(rawCwd); + const globalDirs = discoverGlobalSkillDirs(); + const projectDirs = discoverProjectSkillDirs(cwd); + const globalNames = new Set(globalDirs.map((d) => d.name)); + const projectNames = new Set(projectDirs.map((d) => d.name)); const result = await runWithLayer( Effect.gen(function* () { const skill = yield* SkillService; - return yield* skill.listWithStatus(resolveWorkspaceCwd(c.req.query('cwd'))); + return yield* skill.listWithStatus(cwd); }) ); if (!result.ok) { const { status, body } = errorResponse(result.error); return c.json(body, status as any); } - return c.json(result.value); + return c.json(result.value.map((s) => { + const isFromProject = projectNames.has(s.name); + const isFromGlobal = globalNames.has(s.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...s, + source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + hasProjectOverride, + }; + })); }); settingsRouter.post('/skills', async (c) => { const body = (await c.req.json()) as { name: string; enabled: boolean }; - const result = await runWithLayer( - Effect.gen(function* () { - const skill = yield* SkillService; - const cwd = resolveWorkspaceCwd(c.req.query('cwd')); - return yield* body.enabled - ? skill.enableSkill(cwd, body.name) - : skill.disableSkill(cwd, body.name); - }) - ); - if (!result.ok) { - const { status, body: resp } = errorResponse(result.error); - return c.json(resp, status as any); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + setGlobalSkillDisabledState(body.name, !body.enabled); + return c.json({ ok: true }); } + const cwd = resolveWorkspaceCwd(rawCwd); + setProjectSkillDisabledState(cwd, body.name, !body.enabled); return c.json({ ok: true }); }); // ---- Subagent enabled ---- settingsRouter.get('/subagent/enabled', (c) => { - return c.json({ enabled: getSubagentEnabledState() }); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + return c.json({ enabled: getSubagentEnabledState(), source: 'global' }); + } + const cwd = resolveWorkspaceCwd(rawCwd); + const projectVal = getProjectSubagentEnabledState(cwd); + return c.json({ + enabled: resolveSubagentEnabled(cwd), + source: projectVal !== undefined ? 'project' : 'global', + }); }); settingsRouter.post('/subagent/enabled', async (c) => { const body = (await c.req.json()) as { enabled: boolean }; - setSubagentEnabledState(body.enabled); + const rawCwd = c.req.query('cwd'); + if (isGlobalCwd(rawCwd)) { + setSubagentEnabledState(body.enabled); + } else { + setProjectSubagentEnabledState(resolveWorkspaceCwd(rawCwd), body.enabled); + } + return c.json({ ok: true }); +}); + +settingsRouter.post('/subagent/enabled/reset', async (c) => { + const rawCwd = c.req.query('cwd'); + resetProjectSubagentEnabledState(resolveWorkspaceCwd(rawCwd)); return c.json({ ok: true }); }); diff --git a/packages/codingcode/src/skills/config.ts b/packages/codingcode/src/skills/config.ts index c07b898..7bd09a5 100644 --- a/packages/codingcode/src/skills/config.ts +++ b/packages/codingcode/src/skills/config.ts @@ -1,7 +1,7 @@ -import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; +import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync } from 'fs'; import { join, basename } from 'path'; import { homedir } from 'os'; -import { parse as parseYaml } from 'yaml'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; interface SkillFrontMatter { name: string; @@ -105,3 +105,114 @@ export function getMimeType(filePath: string): string { }; return map[ext ?? ''] ?? 'application/octet-stream'; } + +// ---- 全局级 Skill disabled 状态:持久化到 ~/.codingcode/config.yaml ---- + +export function getGlobalSkillDisabledState(skillName: string): boolean { + try { + const p = join(homedir(), '.codingcode', 'config.yaml'); + if (!existsSync(p)) return false; + const raw = readFileSync(p, 'utf8'); + const config = parseYaml(raw) as any; + const disabled = config.skills?.disabledSkills as Record; + return disabled?.[skillName] ?? false; + } catch { + return false; + } +} + +export function setGlobalSkillDisabledState(skillName: string, disabled: boolean): void { + const dir = join(homedir(), '.codingcode'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = join(dir, 'config.yaml'); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + const skills = (existing.skills as Record) ?? {}; + const disabledSkills = (skills.disabledSkills as Record) ?? {}; + disabledSkills[skillName] = disabled; + skills.disabledSkills = disabledSkills; + existing.skills = skills; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +// ---- 项目级 Skill disabled 状态:持久化到 .codingcode/config.yaml ---- + +export function getProjectSkillDisabledState(projectRoot: string, skillName: string): boolean | undefined { + const p = join(projectRoot, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return undefined; + try { + const raw = readFileSync(p, 'utf8'); + const config = parseYaml(raw) as any; + const disabled = config.skills?.disabledSkills as Record; + return disabled?.[skillName]; + } catch { + return undefined; + } +} + +export function setProjectSkillDisabledState(projectRoot: string, skillName: string, disabled: boolean): void { + const dir = join(projectRoot, '.codingcode'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = join(dir, 'config.yaml'); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + const skills = (existing.skills as Record) ?? {}; + const disabledSkills = (skills.disabledSkills as Record) ?? {}; + disabledSkills[skillName] = disabled; + skills.disabledSkills = disabledSkills; + existing.skills = skills; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +export function resetProjectSkillDisabledState(projectRoot: string, skillName: string): void { + const p = join(projectRoot, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return; + const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record; + const skills = (existing.skills as Record) ?? {}; + const disabledSkills = skills.disabledSkills as Record; + if (disabledSkills) { + delete disabledSkills[skillName]; + skills.disabledSkills = disabledSkills; + } + existing.skills = skills; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +// 解析最终生效的 Skill disabled 状态:项目级 > 全局级 +export function resolveSkillDisabled(projectRoot: string, skillName: string): boolean { + const projectVal = getProjectSkillDisabledState(projectRoot, skillName); + if (projectVal !== undefined) return projectVal; + return getGlobalSkillDisabledState(skillName); +} + +// ---- 辅助函数:分别获取全局/项目级 Skill 目录 ---- + +export function discoverGlobalSkillDirs(): SkillDirectory[] { + const dirs: SkillDirectory[] = []; + const globalSkillsDir = join(homedir(), '.codingcode', 'skills'); + if (existsSync(globalSkillsDir)) { + for (const entry of readdirSync(globalSkillsDir)) { + const dirPath = join(globalSkillsDir, entry); + if (statSync(dirPath).isDirectory()) { + dirs.push({ dirPath, name: entry }); + } + } + } + return dirs; +} + +export function discoverProjectSkillDirs(projectRoot: string): SkillDirectory[] { + const dirs: SkillDirectory[] = []; + const projectSkillsDir = join(projectRoot, '.codingcode', 'skills'); + if (existsSync(projectSkillsDir)) { + for (const entry of readdirSync(projectSkillsDir)) { + const dirPath = join(projectSkillsDir, entry); + if (statSync(dirPath).isDirectory()) { + dirs.push({ dirPath, name: entry }); + } + } + } + return dirs; +} diff --git a/packages/codingcode/src/skills/index.ts b/packages/codingcode/src/skills/index.ts index 46e3050..24ff490 100644 --- a/packages/codingcode/src/skills/index.ts +++ b/packages/codingcode/src/skills/index.ts @@ -1,5 +1,5 @@ import { Effect } from 'effect'; -import { discoverSkillDirs } from './config'; +import { discoverSkillDirs, resolveSkillDisabled, setProjectSkillDisabledState } from './config'; import { loadSkill } from './loader'; import type { Skill } from './types'; @@ -7,7 +7,6 @@ export type { Skill } from './types'; export class SkillService extends Effect.Service()('Skill', { effect: Effect.gen(function* () { - const disabledByProject = new Map>(); const cachedByProject = new Map(); function readAll(projectPath: string): Skill[] { @@ -23,24 +22,15 @@ export class SkillService extends Effect.Service()('Skill', { return skills; } - function getDisabled(projectPath: string): Set { - let set = disabledByProject.get(projectPath); - if (!set) { - set = new Set(); - disabledByProject.set(projectPath, set); - } - return set; - } - return { getAll: (projectPath: string): Effect.Effect => Effect.sync(() => - readAll(projectPath).filter((s) => !getDisabled(projectPath).has(s.name)) + readAll(projectPath).filter((s) => !resolveSkillDisabled(projectPath, s.name)) ), findByName: (projectPath: string, name: string): Effect.Effect => Effect.sync(() => { - if (getDisabled(projectPath).has(name)) return undefined; + if (resolveSkillDisabled(projectPath, name)) return undefined; return readAll(projectPath).find((s) => s.name === name); }), @@ -49,7 +39,7 @@ export class SkillService extends Effect.Service()('Skill', { const match = query.match(/^@([a-zA-Z0-9-]+)(?:\s+|$)/); if (!match) return undefined; const name = match[1]!; - if (getDisabled(projectPath).has(name)) return undefined; + if (resolveSkillDisabled(projectPath, name)) return undefined; return readAll(projectPath).find((s) => s.name === name); }), @@ -59,10 +49,10 @@ export class SkillService extends Effect.Service()('Skill', { matcher: (all: readonly Skill[], q: string) => Effect.Effect ): Effect.Effect => Effect.gen(function* () { - const all = readAll(projectPath).filter((s) => !getDisabled(projectPath).has(s.name)); + const all = readAll(projectPath).filter((s) => !resolveSkillDisabled(projectPath, s.name)); const name = yield* matcher(all, query); if (!name) return undefined; - if (getDisabled(projectPath).has(name)) return undefined; + if (resolveSkillDisabled(projectPath, name)) return undefined; return all.find((s) => s.name === name); }), @@ -75,7 +65,7 @@ export class SkillService extends Effect.Service()('Skill', { const match = query.match(/^@([a-zA-Z0-9-]+)(?:\s+|$)/); if (!match) return undefined; const name = match[1]!; - if (getDisabled(projectPath).has(name)) return undefined; + if (resolveSkillDisabled(projectPath, name)) return undefined; return readAll(projectPath).find((s) => s.name === name); }); const actualQuery = query.replace(/^@[a-zA-Z0-9-]+\s*/, ''); @@ -84,12 +74,12 @@ export class SkillService extends Effect.Service()('Skill', { disableSkill: (projectPath: string, name: string): Effect.Effect => Effect.sync(() => { - getDisabled(projectPath).add(name); + setProjectSkillDisabledState(projectPath, name, true); }), enableSkill: (projectPath: string, name: string): Effect.Effect => Effect.sync(() => { - getDisabled(projectPath).delete(name); + setProjectSkillDisabledState(projectPath, name, false); }), listWithStatus: ( @@ -99,7 +89,7 @@ export class SkillService extends Effect.Service()('Skill', { readAll(projectPath).map((s) => ({ name: s.name, description: s.description, - enabled: !getDisabled(projectPath).has(s.name), + enabled: !resolveSkillDisabled(projectPath, s.name), })) ), diff --git a/packages/codingcode/src/subagent/loader.ts b/packages/codingcode/src/subagent/loader.ts index e5ca18f..714c085 100644 --- a/packages/codingcode/src/subagent/loader.ts +++ b/packages/codingcode/src/subagent/loader.ts @@ -89,6 +89,7 @@ function buildProfileFromFrontmatter( maxSteps: typeof frontmatter.maxSteps === 'number' ? frontmatter.maxSteps : undefined, model: typeof frontmatter.model === 'string' ? frontmatter.model : undefined, hooks: Array.isArray(frontmatter.hooks) ? (frontmatter.hooks as any[]) : undefined, + disabled: Boolean(frontmatter.disabled) || false, }; } @@ -133,6 +134,7 @@ function serializeAgentProfile(profile: AgentProfile): string { if (profile.readonly) fm.push(`readonly: true`); if (profile.maxSteps !== undefined) fm.push(`maxSteps: ${profile.maxSteps}`); if (profile.model) fm.push(`model: ${profile.model}`); + if (profile.disabled) fm.push(`disabled: true`); fm.push('---'); fm.push(''); fm.push(profile.systemPrompt || 'You are a specialized agent.'); @@ -262,19 +264,39 @@ export function resolveAgentProfile( return undefined; } -export const DEFAULT_MAIN_PROFILE: AgentProfile = { - name: 'default-main', - description: 'Default project assistant', - tools: [ - 'read_file', - 'write_file', - 'edit_file', - 'search_code', - 'search_files', - 'todo_write', - 'dispatch_agent', - 'tool_search', - ], - readonly: false, - maxSteps: 30, -}; +function getGlobalAgentsDir(): string { + return join(homedir(), '.codingcode', 'agents'); +} + +function findAgentFileInDir(dirPath: string, name: string): string | null { + if (!existsSync(dirPath)) return null; + const files = readdirSync(dirPath).filter((f) => f.endsWith('.md')); + for (const file of files) { + const filePath = join(dirPath, file); + const content = readFileSync(filePath, 'utf-8'); + const { frontmatter } = parseFrontmatter(content); + if (frontmatter.name === name) return filePath; + } + return null; +} + +export function writeGlobalAgentProfile(profile: AgentProfile): void { + const agentsDir = getGlobalAgentsDir(); + if (!existsSync(agentsDir)) mkdirSync(agentsDir, { recursive: true }); + const existing = findAgentFileInDir(agentsDir, profile.name); + const filePath = existing ?? join(agentsDir, agentNameToFilename(profile.name)); + writeFileSync(filePath, serializeAgentProfile(profile), 'utf-8'); +} + +export function updateGlobalAgentProfile(oldName: string, profile: AgentProfile): void { + if (oldName !== profile.name) { + const oldFile = findAgentFileInDir(getGlobalAgentsDir(), oldName); + if (oldFile) unlinkSync(oldFile); + } + writeGlobalAgentProfile(profile); +} + +export function deleteGlobalAgentProfile(name: string): void { + const filePath = findAgentFileInDir(getGlobalAgentsDir(), name); + if (filePath) unlinkSync(filePath); +} diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts index 0b7f7a2..4c8dd5f 100644 --- a/packages/codingcode/src/subagent/registry.ts +++ b/packages/codingcode/src/subagent/registry.ts @@ -1,5 +1,9 @@ import { Effect } from 'effect'; import type { UserHookConfig } from '../hooks/config.js'; +import { loadConfig, getUserConfigPath } from '@codingcode/infra'; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { dirname, join } from 'path'; export interface AgentProfile { name: string; @@ -11,29 +15,160 @@ export interface AgentProfile { maxSteps?: number; model?: string; hooks?: UserHookConfig[]; + disabled?: boolean; } -let _globalSubagentEnabled = true; +// ---- 全局级子智能体开关 ---- + export function getSubagentEnabledState(): boolean { - return _globalSubagentEnabled; + try { + const config = loadConfig() as any; + return config.subagent?.enabled ?? true; + } catch { + return true; + } } + export function setSubagentEnabledState(v: boolean): void { - _globalSubagentEnabled = v; + const p = getUserConfigPath(); + const dir = dirname(p); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + const subagent = (existing.subagent as Record) ?? {}; + existing.subagent = { ...subagent, enabled: v }; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +// ---- 项目级子智能体开关:持久化到 .codingcode/config.yaml ---- + +export function getProjectSubagentEnabledState(projectCwd: string): boolean | undefined { + const p = join(projectCwd, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return undefined; + try { + const raw = readFileSync(p, 'utf8'); + const config = parseYaml(raw) as any; + return config.subagent?.enabled; + } catch { + return undefined; + } +} + +export function setProjectSubagentEnabledState(projectCwd: string, v: boolean): void { + const dir = join(projectCwd, '.codingcode'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = join(dir, 'config.yaml'); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + const subagent = (existing.subagent as Record) ?? {}; + existing.subagent = { ...subagent, enabled: v }; + writeFileSync(p, stringifyYaml(existing), 'utf8'); } -const _disabledAgents = new Set(); -export function setAgentDisabledState(name: string, disabled: boolean): void { - if (disabled) _disabledAgents.add(name); - else _disabledAgents.delete(name); +export function resetProjectSubagentEnabledState(projectCwd: string): void { + const p = join(projectCwd, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return; + const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record; + const subagent = (existing.subagent as Record) ?? {}; + delete subagent.enabled; + if (Object.keys(subagent).length === 0) { + delete existing.subagent; + } else { + existing.subagent = subagent; + } + writeFileSync(p, stringifyYaml(existing), 'utf8'); } -export function isAgentDisabledState(name: string): boolean { - return _disabledAgents.has(name); + +// 解析最终生效的子智能体开关:项目级 > 全局级 +export function resolveSubagentEnabled(projectCwd: string): boolean { + const projectVal = getProjectSubagentEnabledState(projectCwd); + if (projectVal !== undefined) return projectVal; + return getSubagentEnabledState(); +} + +// ---- 全局级 agent disabled 状态:持久化到 ~/.codingcode/config.yaml ---- + +export function getGlobalAgentDisabledState(agentName: string): boolean { + try { + const config = loadConfig() as any; + const disabled = config.subagent?.disabledAgents as Record; + return disabled?.[agentName] ?? false; + } catch { + return false; + } +} + +export function setGlobalAgentDisabledState(agentName: string, disabled: boolean): void { + const p = getUserConfigPath(); + const dir = dirname(p); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + const subagent = (existing.subagent as Record) ?? {}; + const disabledAgents = (subagent.disabledAgents as Record) ?? {}; + disabledAgents[agentName] = disabled; + subagent.disabledAgents = disabledAgents; + existing.subagent = subagent; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +// ---- 项目级 agent disabled 状态:持久化到 .codingcode/config.yaml ---- + +export function getProjectAgentDisabledState(projectCwd: string, agentName: string): boolean | undefined { + const p = join(projectCwd, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return undefined; + try { + const raw = readFileSync(p, 'utf8'); + const config = parseYaml(raw) as any; + const disabled = config.subagent?.disabledAgents as Record; + return disabled?.[agentName]; + } catch { + return undefined; + } +} + +export function setProjectAgentDisabledState(projectCwd: string, agentName: string, disabled: boolean): void { + const dir = join(projectCwd, '.codingcode'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const p = join(dir, 'config.yaml'); + const existing: Record = existsSync(p) + ? (parseYaml(readFileSync(p, 'utf8')) as Record) + : {}; + const subagent = (existing.subagent as Record) ?? {}; + const disabledAgents = (subagent.disabledAgents as Record) ?? {}; + disabledAgents[agentName] = disabled; + subagent.disabledAgents = disabledAgents; + existing.subagent = subagent; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +export function resetProjectAgentDisabledState(projectCwd: string, agentName: string): void { + const p = join(projectCwd, '.codingcode', 'config.yaml'); + if (!existsSync(p)) return; + const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record; + const subagent = (existing.subagent as Record) ?? {}; + const disabledAgents = subagent.disabledAgents as Record; + if (disabledAgents) { + delete disabledAgents[agentName]; + subagent.disabledAgents = disabledAgents; + } + existing.subagent = subagent; + writeFileSync(p, stringifyYaml(existing), 'utf8'); +} + +// 解析最终生效的 agent disabled 状态:项目级 > 全局级 +export function resolveAgentDisabled(projectCwd: string, agentName: string): boolean { + const projectVal = getProjectAgentDisabledState(projectCwd, agentName); + if (projectVal !== undefined) return projectVal; + return getGlobalAgentDisabledState(agentName); } export class SubagentRegistry extends Effect.Service()('SubagentRegistry', { effect: Effect.gen(function* () { const map = new Map(); - const disabledAgents = new Set(); return { register: (profile: AgentProfile): void => { @@ -50,26 +185,7 @@ export class SubagentRegistry extends Effect.Service()('Subage reset: (): void => { map.clear(); - _globalSubagentEnabled = true; - disabledAgents.clear(); - _disabledAgents.clear(); - }, - - setEnabled: (v: boolean): void => { - _globalSubagentEnabled = v; - }, - isEnabled: (): boolean => _globalSubagentEnabled, - - disableAgent: (name: string): void => { - disabledAgents.add(name); - _disabledAgents.add(name); - }, - enableAgent: (name: string): void => { - disabledAgents.delete(name); - _disabledAgents.delete(name); }, - isAgentDisabled: (name: string): boolean => - disabledAgents.has(name) || _disabledAgents.has(name), }; }), }) {} diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index 9c4ffa9..689a7c7 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -8,6 +8,7 @@ import type { ApprovalService } from '../../../approval/index.js'; import type { HookService } from '../../../hooks/registry.js'; import type { McpService } from '../../../mcp/index.js'; import { findModel, createClient } from '../../../llm/factory.js'; +import { resolveSubagentEnabled, resolveAgentDisabled } from '../../../subagent/registry.js'; interface DispatchAgentDeps { session: SessionService; @@ -28,14 +29,25 @@ export function createDispatchAgentTool(deps: DispatchAgentDeps): ToolDefinition prompt: z.string().min(1).describe('task description for the subagent'), }), execute: async (args: any, ctx: any) => { - const { agent: agentName, prompt } = args; - const projectPath = ctx?.projectPath || process.cwd(); + const { agent: agentName, prompt } = args; - // Get profile - const profile = deps.runtime.resolveSubagentProfile(projectPath, agentName); - if (!profile) { - throw new Error(`Unknown subagent: ${agentName}`); - } + const projectPath = ctx?.projectPath || process.cwd(); + + // Check global subagent switch + if (!resolveSubagentEnabled(projectPath)) { + throw new Error('Subagent dispatch is disabled in global settings'); + } + + // Get profile + const profile = deps.runtime.resolveSubagentProfile(projectPath, agentName); + if (!profile) { + throw new Error(`Unknown subagent: ${agentName}`); + } + + // Check individual agent disabled state + if (resolveAgentDisabled(projectPath, agentName)) { + throw new Error(`Subagent '${agentName}' is disabled`); + } if (!ctx?.agentRunner?.agentService || !ctx?.agentRunner?.llm) { throw new Error('dispatch_agent requires agentRunner context'); @@ -106,12 +118,16 @@ export function createDispatchAgentTool(deps: DispatchAgentDeps): ToolDefinition // Build tool policy from profile const childPolicy = deps.runtime.getToolPolicy(profile); + // Get MCP tools for subagent + const mcpTools = deps.mcp.listProjectMcpTools(projectPath); + // Run subagent const stream = agentService.runStream({ state: childState, llm, systemOverride: profile.systemPrompt, toolPolicy: childPolicy, + mcpTools, abortSignal: ctx?.signal, parentSessionId: ctx?.sessionId, agentName: agentName, diff --git a/packages/codingcode/test/client/direct/settings.test.ts b/packages/codingcode/test/client/direct/settings.test.ts new file mode 100644 index 0000000..1a29dce --- /dev/null +++ b/packages/codingcode/test/client/direct/settings.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { createDirectSettingsClient } from '../../../src/client/direct/settings.js'; + +vi.mock('../../../src/mcp/index.js', () => ({ + McpService: {} as any, +})); + +vi.mock('../../../src/skills/index.js', () => ({ + SkillService: {} as any, +})); + +vi.mock('../../../src/approval/index.js', () => ({ + getGlobalPermissionMode: vi.fn().mockReturnValue('auto'), + setGlobalPermissionMode: vi.fn(), +})); + +vi.mock('../../../src/mcp/config.js', () => ({ + loadMcpConfig: vi.fn().mockReturnValue([]), + writeMcpConfig: vi.fn(), + resolveMcpDisabled: vi.fn().mockReturnValue(false), + setGlobalMcpDisabledState: vi.fn(), + setProjectMcpDisabledState: vi.fn(), + resetProjectMcpDisabledState: vi.fn(), +})); + +vi.mock('../../../src/subagent/loader.js', () => ({ + loadAgentProfiles: vi.fn().mockReturnValue([]), + writeAgentProfile: vi.fn(), + updateAgentProfile: vi.fn(), + deleteAgentProfile: vi.fn(), +})); + +vi.mock('../../../src/subagent/registry.js', () => ({ + EXPLORE_PROFILE: { name: 'explore', description: 'Explore', tools: ['read_file'], readonly: true, maxSteps: 30 }, + setSubagentEnabledState: vi.fn(), + resolveSubagentEnabled: vi.fn().mockReturnValue(true), + getProjectSubagentEnabledState: vi.fn().mockReturnValue(undefined), + setProjectSubagentEnabledState: vi.fn(), + resetProjectSubagentEnabledState: vi.fn(), + setGlobalAgentDisabledState: vi.fn(), + setProjectAgentDisabledState: vi.fn(), + resetProjectAgentDisabledState: vi.fn(), + resolveAgentDisabled: vi.fn().mockReturnValue(false), + getProjectAgentDisabledState: vi.fn().mockReturnValue(undefined), +})); + +vi.mock('../../../src/hooks/config.js', () => ({ + loadHookConfigs: vi.fn().mockReturnValue([]), + writeHookConfigs: vi.fn(), + resolveHookDisabled: vi.fn().mockReturnValue(false), + setGlobalHookDisabledState: vi.fn(), + setProjectHookDisabledState: vi.fn(), + resetProjectHookDisabledState: vi.fn(), +})); + +vi.mock('../../../src/hooks/executor.js', () => ({ + setHookRuntimeEnabled: vi.fn(), +})); + +vi.mock('../../../src/memory/config.js', () => ({ + getMemoryConfig: vi.fn().mockReturnValue({ enabled: true, disabledTypes: [], extraTypes: [] }), + getAllTypesWithStatus: vi.fn().mockReturnValue([]), + setMemoryTypeDisabled: vi.fn(), + addMemoryExtraType: vi.fn(), + updateMemoryExtraType: vi.fn(), + deleteMemoryExtraType: vi.fn(), +})); + +vi.mock('../../../src/memory/index.js', () => ({ + getMemoryEnabled: vi.fn().mockReturnValue(true), + setMemoryEnabled: vi.fn(), +})); + +vi.mock('../../../src/core/error.js', () => ({ + AlreadyExistsError: class AlreadyExistsError extends Error { + constructor(msg: string) { super(msg); this.name = 'AlreadyExistsError'; } + }, + NotFoundError: class NotFoundError extends Error { + constructor(msg: string) { super(msg); this.name = 'NotFoundError'; } + }, +})); + +const mockRunWithLayer = vi.fn().mockResolvedValue(undefined); + +describe('createDirectSettingsClient - reset APIs', () => { + let client: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + client = createDirectSettingsClient(mockRunWithLayer); + }); + + describe('resetSubagentEnabled', () => { + it('calls resetProjectSubagentEnabledState with cwd', async () => { + const { resetProjectSubagentEnabledState } = await import('../../../src/subagent/registry.js'); + await client.resetSubagentEnabled({ cwd: '/my-project' }); + expect(resetProjectSubagentEnabledState).toHaveBeenCalledWith('/my-project'); + }); + }); + + describe('resetAgentDisabled', () => { + it('calls resetProjectAgentDisabledState with cwd and name', async () => { + const { resetProjectAgentDisabledState } = await import('../../../src/subagent/registry.js'); + await client.resetAgentDisabled({ name: 'my-agent', cwd: '/my-project' }); + expect(resetProjectAgentDisabledState).toHaveBeenCalledWith('/my-project', 'my-agent'); + }); + }); + + describe('resetMcpDisabled', () => { + it('calls resetProjectMcpDisabledState with cwd and name', async () => { + const { resetProjectMcpDisabledState } = await import('../../../src/mcp/config.js'); + await client.resetMcpDisabled({ name: 'my-server', cwd: '/my-project' }); + expect(resetProjectMcpDisabledState).toHaveBeenCalledWith('/my-project', 'my-server'); + }); + }); + + describe('resetHookDisabled', () => { + it('calls resetProjectHookDisabledState with cwd and name', async () => { + const { resetProjectHookDisabledState } = await import('../../../src/hooks/config.js'); + await client.resetHookDisabled({ name: 'my-hook', cwd: '/my-project' }); + expect(resetProjectHookDisabledState).toHaveBeenCalledWith('/my-project', 'my-hook'); + }); + }); +}); + +describe('createDirectSettingsClient - updated signatures with cwd', () => { + let client: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + client = createDirectSettingsClient(mockRunWithLayer); + }); + + describe('getSubagentEnabled', () => { + it('returns enabled and source from resolveSubagentEnabled', async () => { + const { resolveSubagentEnabled, getProjectSubagentEnabledState } = await import('../../../src/subagent/registry.js'); + vi.mocked(resolveSubagentEnabled).mockReturnValue(true); + vi.mocked(getProjectSubagentEnabledState).mockReturnValue(undefined); + const result = await client.getSubagentEnabled({ cwd: '/my-project' }); + expect(result).toEqual({ enabled: true, source: 'global' }); + expect(resolveSubagentEnabled).toHaveBeenCalledWith('/my-project'); + }); + + it('returns source=project when project override exists', async () => { + const { resolveSubagentEnabled, getProjectSubagentEnabledState } = await import('../../../src/subagent/registry.js'); + vi.mocked(resolveSubagentEnabled).mockReturnValue(false); + vi.mocked(getProjectSubagentEnabledState).mockReturnValue(false); + const result = await client.getSubagentEnabled({ cwd: '/my-project' }); + expect(result).toEqual({ enabled: false, source: 'project' }); + }); + }); + + describe('setSubagentEnabled', () => { + it('calls setSubagentEnabledState for global cwd', async () => { + const { setSubagentEnabledState } = await import('../../../src/subagent/registry.js'); + await client.setSubagentEnabled({ enabled: false, cwd: 'global' }); + expect(setSubagentEnabledState).toHaveBeenCalledWith(false); + }); + + it('calls setProjectSubagentEnabledState for project cwd', async () => { + const { setProjectSubagentEnabledState } = await import('../../../src/subagent/registry.js'); + await client.setSubagentEnabled({ enabled: false, cwd: '/my-project' }); + expect(setProjectSubagentEnabledState).toHaveBeenCalledWith('/my-project', false); + }); + }); + + describe('setAgentDisabled', () => { + it('calls setGlobalAgentDisabledState for global cwd', async () => { + const { setGlobalAgentDisabledState } = await import('../../../src/subagent/registry.js'); + await client.setAgentDisabled({ name: 'my-agent', disabled: true, cwd: 'global' }); + expect(setGlobalAgentDisabledState).toHaveBeenCalledWith('my-agent', true); + }); + + it('calls setProjectAgentDisabledState for project cwd', async () => { + const { setProjectAgentDisabledState } = await import('../../../src/subagent/registry.js'); + await client.setAgentDisabled({ name: 'my-agent', disabled: true, cwd: '/my-project' }); + expect(setProjectAgentDisabledState).toHaveBeenCalledWith('/my-project', 'my-agent', true); + }); + }); + + describe('setMcpDisabled', () => { + it('calls setGlobalMcpDisabledState for global cwd', async () => { + const { setGlobalMcpDisabledState } = await import('../../../src/mcp/config.js'); + await client.setMcpDisabled({ name: 'my-server', disabled: true, cwd: 'global' }); + expect(setGlobalMcpDisabledState).toHaveBeenCalledWith('my-server', true); + }); + + it('calls setProjectMcpDisabledState for project cwd', async () => { + const { setProjectMcpDisabledState } = await import('../../../src/mcp/config.js'); + await client.setMcpDisabled({ name: 'my-server', disabled: true, cwd: '/my-project' }); + expect(setProjectMcpDisabledState).toHaveBeenCalledWith('/my-project', 'my-server', true); + }); + }); + + describe('setHookDisabled', () => { + it('calls setGlobalHookDisabledState for global cwd', async () => { + const { setGlobalHookDisabledState } = await import('../../../src/hooks/config.js'); + await client.setHookDisabled({ cwd: 'global', name: 'my-hook', disabled: true }); + expect(setGlobalHookDisabledState).toHaveBeenCalledWith('my-hook', true); + }); + + it('calls setProjectHookDisabledState for project cwd', async () => { + const { setProjectHookDisabledState } = await import('../../../src/hooks/config.js'); + await client.setHookDisabled({ cwd: '/my-project', name: 'my-hook', disabled: true }); + expect(setProjectHookDisabledState).toHaveBeenCalledWith('/my-project', 'my-hook', true); + }); + }); + + describe('toggleSkill', () => { + it('passes cwd to SkillService via runWithLayer', async () => { + mockRunWithLayer.mockResolvedValue(undefined); + await client.toggleSkill({ name: 'my-skill', enabled: true, cwd: '/my-project' }); + expect(mockRunWithLayer).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/codingcode/test/client/http/settings.test.ts b/packages/codingcode/test/client/http/settings.test.ts new file mode 100644 index 0000000..910dfe3 --- /dev/null +++ b/packages/codingcode/test/client/http/settings.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { createHttpSettingsClient } from '../../../src/client/http/settings.js'; + +function createMockRequest() { + return { + apiGet: vi.fn().mockResolvedValue({ enabled: true }), + apiPost: vi.fn().mockResolvedValue(undefined), + apiPut: vi.fn().mockResolvedValue(undefined), + apiDelete: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('createHttpSettingsClient - reset APIs', () => { + let request: ReturnType; + let client: ReturnType; + + beforeEach(() => { + request = createMockRequest(); + client = createHttpSettingsClient(request as any); + }); + + describe('resetSubagentEnabled', () => { + it('POSTs to /settings/subagent/enabled/reset with cwd query param', async () => { + await client.resetSubagentEnabled({ cwd: '/my-project' }); + expect(request.apiPost).toHaveBeenCalledWith( + '/api/settings/subagent/enabled/reset?cwd=%2Fmy-project', + {}, + ); + }); + }); + + describe('resetAgentDisabled', () => { + it('POSTs to /settings/agents/:name/disabled/reset with cwd query param', async () => { + await client.resetAgentDisabled({ name: 'my-agent', cwd: '/my-project' }); + expect(request.apiPost).toHaveBeenCalledWith( + '/api/settings/agents/my-agent/disabled/reset?cwd=%2Fmy-project', + {}, + ); + }); + + it('encodes agent name with special characters', async () => { + await client.resetAgentDisabled({ name: 'my agent', cwd: '/my-project' }); + expect(request.apiPost).toHaveBeenCalledWith( + '/api/settings/agents/my%20agent/disabled/reset?cwd=%2Fmy-project', + {}, + ); + }); + }); + + describe('resetMcpDisabled', () => { + it('POSTs to /settings/mcp/:name/disabled/reset with cwd query param', async () => { + await client.resetMcpDisabled({ name: 'my-server', cwd: '/my-project' }); + expect(request.apiPost).toHaveBeenCalledWith( + '/api/settings/mcp/my-server/disabled/reset?cwd=%2Fmy-project', + {}, + ); + }); + }); + + describe('resetHookDisabled', () => { + it('POSTs to /settings/hooks/:name/disabled/reset with cwd query param', async () => { + await client.resetHookDisabled({ name: 'my-hook', cwd: '/my-project' }); + expect(request.apiPost).toHaveBeenCalledWith( + '/api/settings/hooks/my-hook/disabled/reset?cwd=%2Fmy-project', + {}, + ); + }); + }); +}); + +describe('createHttpSettingsClient - updated signatures with cwd', () => { + let request: ReturnType; + let client: ReturnType; + + beforeEach(() => { + request = createMockRequest(); + client = createHttpSettingsClient(request as any); + }); + + describe('getSubagentEnabled', () => { + it('GETs /settings/subagent/enabled with cwd query param', async () => { + request.apiGet.mockResolvedValue({ enabled: true, source: 'global' }); + const result = await client.getSubagentEnabled({ cwd: '/my-project' }); + expect(request.apiGet).toHaveBeenCalledWith( + '/api/settings/subagent/enabled?cwd=%2Fmy-project', + ); + expect(result).toEqual({ enabled: true, source: 'global' }); + }); + }); + + describe('setSubagentEnabled', () => { + it('POSTs to /settings/subagent/enabled with cwd query param and enabled in body', async () => { + await client.setSubagentEnabled({ enabled: false, cwd: '/my-project' }); + expect(request.apiPost).toHaveBeenCalledWith( + '/api/settings/subagent/enabled?cwd=%2Fmy-project', + { enabled: false }, + ); + }); + }); + + describe('setMcpDisabled', () => { + it('POSTs to /settings/mcp/:name/disabled with cwd query param', async () => { + await client.setMcpDisabled({ name: 'my-server', disabled: true, cwd: '/my-project' }); + expect(request.apiPost).toHaveBeenCalledWith( + '/api/settings/mcp/my-server/disabled?cwd=%2Fmy-project', + { disabled: true }, + ); + }); + }); + + describe('setAgentDisabled', () => { + it('POSTs to /settings/agents/:name/disabled with cwd query param', async () => { + await client.setAgentDisabled({ name: 'my-agent', disabled: true, cwd: '/my-project' }); + expect(request.apiPost).toHaveBeenCalledWith( + '/api/settings/agents/my-agent/disabled?cwd=%2Fmy-project', + { disabled: true }, + ); + }); + }); + + describe('setHookDisabled', () => { + it('POSTs to /settings/hooks/:name/disabled with cwd query param', async () => { + await client.setHookDisabled({ cwd: '/my-project', name: 'my-hook', disabled: true }); + expect(request.apiPost).toHaveBeenCalledWith( + '/api/settings/hooks/my-hook/disabled?cwd=%2Fmy-project', + { disabled: true }, + ); + }); + }); + + describe('toggleSkill', () => { + it('POSTs to /settings/skills with cwd query param', async () => { + await client.toggleSkill({ name: 'my-skill', enabled: true, cwd: '/my-project' }); + expect(request.apiPost).toHaveBeenCalledWith( + '/api/settings/skills?cwd=%2Fmy-project', + { name: 'my-skill', enabled: true }, + ); + }); + }); +}); diff --git a/packages/codingcode/test/context/context.test.ts b/packages/codingcode/test/context/context.test.ts index c6bd14b..c7b11fc 100644 --- a/packages/codingcode/test/context/context.test.ts +++ b/packages/codingcode/test/context/context.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { Effect, Layer } from 'effect'; import { ContextService } from '../../src/context/context.js'; import { SessionService } from '../../src/session/store.js'; @@ -186,6 +186,7 @@ describe('ContextService', () => { getServerToolNames: () => [], disconnectAll: () => Effect.void, status: () => Effect.succeed([]), + listProjectMcpTools: () => [], } as any); const MockApprovalLayer = Layer.succeed(ApprovalService, { diff --git a/packages/codingcode/test/hooks/config-merge.test.ts b/packages/codingcode/test/hooks/config-merge.test.ts new file mode 100644 index 0000000..b5cbaba --- /dev/null +++ b/packages/codingcode/test/hooks/config-merge.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { + loadHookConfigs, + writeHookConfigs, + loadGlobalHookConfigs, + writeGlobalHookConfigs, + resolveHookConfigs, + getGlobalHookDisabledState, + setGlobalHookDisabledState, + getProjectHookDisabledState, + setProjectHookDisabledState, + resetProjectHookDisabledState, + resolveHookDisabled, +} from '../../src/hooks/config.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEST_PROJECT_DIR = join(__dirname, '..', '..', '..', 'test-fixture-hooks-merge'); +const TEST_PROJECT_CODINGCODE = join(TEST_PROJECT_DIR, '.codingcode'); +const TEST_GLOBAL_DIR = join(__dirname, '..', '..', '..', 'test-fixture-global-hooks', '.codingcode'); + +describe('Hooks config merge', () => { + beforeEach(() => { + if (existsSync(TEST_PROJECT_DIR)) + rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + mkdirSync(TEST_PROJECT_CODINGCODE, { recursive: true }); + if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'))) + rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'), { recursive: true, force: true }); + mkdirSync(TEST_GLOBAL_DIR, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TEST_PROJECT_DIR)) + rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'))) + rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'), { recursive: true, force: true }); + }); + + it('should merge global and project hooks, project overrides global', () => { + // Write global hooks + const globalHooks = [ + { name: 'global-hook', point: 'tool.execute.before', type: 'observer' as const, command: 'global-cmd', enabled: true }, + { name: 'shared-hook', point: 'tool.execute.after', type: 'observer' as const, command: 'global-shared-cmd', enabled: true }, + ]; + writeGlobalHookConfigs(globalHooks); + + // Write project hooks + const projectHooks = [ + { name: 'shared-hook', point: 'tool.execute.after', type: 'observer' as const, command: 'project-shared-cmd', enabled: true }, + { name: 'project-hook', point: 'tool.execute.error', type: 'observer' as const, command: 'project-cmd', enabled: true }, + ]; + writeHookConfigs(TEST_PROJECT_DIR, projectHooks); + + const merged = resolveHookConfigs(TEST_PROJECT_DIR); + + expect(merged).toHaveLength(3); + + const globalHook = merged.find((h) => h.name === 'global-hook'); + expect(globalHook).toBeDefined(); + expect(globalHook!.command).toBe('global-cmd'); + + const sharedHook = merged.find((h) => h.name === 'shared-hook'); + expect(sharedHook).toBeDefined(); + expect(sharedHook!.command).toBe('project-shared-cmd'); // project overrides global + + const projectHook = merged.find((h) => h.name === 'project-hook'); + expect(projectHook).toBeDefined(); + expect(projectHook!.command).toBe('project-cmd'); + }); +}); + +describe('Hook disabled state', () => { + const testHook = '__test_hook__'; + + beforeEach(() => { + mkdirSync(TEST_PROJECT_CODINGCODE, { recursive: true }); + setGlobalHookDisabledState(testHook, false); + }); + + afterEach(() => { + rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + setGlobalHookDisabledState(testHook, false); + }); + + it('should default to not disabled globally', () => { + expect(getGlobalHookDisabledState(testHook)).toBe(false); + }); + + it('should persist global disabled state', () => { + setGlobalHookDisabledState(testHook, true); + expect(getGlobalHookDisabledState(testHook)).toBe(true); + }); + + it('should return undefined when project has no config', () => { + expect(getProjectHookDisabledState(TEST_PROJECT_DIR, testHook)).toBe(undefined); + }); + + it('should persist project-level disabled state', () => { + setProjectHookDisabledState(TEST_PROJECT_DIR, testHook, true); + expect(getProjectHookDisabledState(TEST_PROJECT_DIR, testHook)).toBe(true); + }); + + it('should reset project-level disabled state', () => { + setProjectHookDisabledState(TEST_PROJECT_DIR, testHook, true); + resetProjectHookDisabledState(TEST_PROJECT_DIR, testHook); + expect(getProjectHookDisabledState(TEST_PROJECT_DIR, testHook)).toBe(undefined); + }); + + it('resolveHookDisabled should use project-level when set', () => { + setGlobalHookDisabledState(testHook, false); + setProjectHookDisabledState(TEST_PROJECT_DIR, testHook, true); + expect(resolveHookDisabled(TEST_PROJECT_DIR, testHook)).toBe(true); + }); + + it('resolveHookDisabled should fall back to global when project not set', () => { + setGlobalHookDisabledState(testHook, true); + expect(resolveHookDisabled(TEST_PROJECT_DIR, testHook)).toBe(true); + }); + + it('resolveHookDisabled should use project-level enabled over global disabled', () => { + setGlobalHookDisabledState(testHook, true); + setProjectHookDisabledState(TEST_PROJECT_DIR, testHook, false); + expect(resolveHookDisabled(TEST_PROJECT_DIR, testHook)).toBe(false); + }); +}); diff --git a/packages/codingcode/test/mcp/config-merge.test.ts b/packages/codingcode/test/mcp/config-merge.test.ts new file mode 100644 index 0000000..abf6389 --- /dev/null +++ b/packages/codingcode/test/mcp/config-merge.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { + loadMcpConfig, + writeMcpConfig, + loadGlobalMcpConfig, + writeGlobalMcpConfig, + resolveMcpConfig, + getGlobalMcpDisabledState, + setGlobalMcpDisabledState, + getProjectMcpDisabledState, + setProjectMcpDisabledState, + resetProjectMcpDisabledState, + resolveMcpDisabled, +} from '../../src/mcp/config.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEST_PROJECT_DIR = join(__dirname, '..', '..', '..', 'test-fixture-mcp-merge'); +const TEST_PROJECT_CODINGCODE = join(TEST_PROJECT_DIR, '.codingcode'); + +// 模拟全局目录 +const TEST_GLOBAL_DIR = join(__dirname, '..', '..', '..', 'test-fixture-global', '.codingcode'); + +describe('MCP config merge', () => { + beforeEach(() => { + if (existsSync(TEST_PROJECT_DIR)) + rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + mkdirSync(TEST_PROJECT_CODINGCODE, { recursive: true }); + if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global'))) + rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global'), { recursive: true, force: true }); + mkdirSync(TEST_GLOBAL_DIR, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(TEST_PROJECT_DIR)) + rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global'))) + rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global'), { recursive: true, force: true }); + }); + + it('should merge global and project configs, project overrides global', () => { + // Write global config + writeGlobalMcpConfig([ + { name: 'global-server', transport: 'stdio', command: 'global-cmd', disabled: false, toolCount: 0 } as any, + { name: 'shared-server', transport: 'stdio', command: 'global-shared-cmd', disabled: false, toolCount: 0 } as any, + ]); + + // Write project config + writeMcpConfig(TEST_PROJECT_DIR, [ + { name: 'shared-server', transport: 'stdio', command: 'project-shared-cmd', disabled: false, toolCount: 0 } as any, + { name: 'project-server', transport: 'stdio', command: 'project-cmd', disabled: false, toolCount: 0 } as any, + ]); + + const merged = resolveMcpConfig(TEST_PROJECT_DIR); + + // Should have 3 servers: global-server, shared-server (project override), project-server + expect(merged).toHaveLength(3); + + const globalServer = merged.find((s) => s.name === 'global-server'); + expect(globalServer).toBeDefined(); + expect((globalServer as any).command).toBe('global-cmd'); + + const sharedServer = merged.find((s) => s.name === 'shared-server'); + expect(sharedServer).toBeDefined(); + expect((sharedServer as any).command).toBe('project-shared-cmd'); // project overrides global + + const projectServer = merged.find((s) => s.name === 'project-server'); + expect(projectServer).toBeDefined(); + expect((projectServer as any).command).toBe('project-cmd'); + }); + + it('should return only project config when no global config', () => { + writeMcpConfig(TEST_PROJECT_DIR, [ + { name: 'project-server', transport: 'stdio', command: 'project-cmd', disabled: false, toolCount: 0 } as any, + ]); + + const merged = resolveMcpConfig(TEST_PROJECT_DIR); + expect(merged).toHaveLength(1); + expect(merged[0].name).toBe('project-server'); + }); + + it('should return only global config when no project config', () => { + writeGlobalMcpConfig([ + { name: 'global-server', transport: 'stdio', command: 'global-cmd', disabled: false, toolCount: 0 } as any, + ]); + + const merged = resolveMcpConfig(TEST_PROJECT_DIR); + expect(merged).toHaveLength(1); + expect(merged[0].name).toBe('global-server'); + }); +}); + +// Helper to write global config to test directory +function writeGlobalMcpConfig(servers: any[]): void { + // Override the global config dir by writing to the test fixture + const p = join(TEST_GLOBAL_DIR, 'mcp.yaml'); + writeFileSync(p, `servers:\n${servers.map((s) => ` - name: ${s.name}\n transport: ${s.transport}\n command: ${s.command}\n`).join('')}`, 'utf8'); +} + +describe('MCP disabled state', () => { + const testServer = '__test_mcp_server__'; + + beforeEach(() => { + mkdirSync(TEST_PROJECT_CODINGCODE, { recursive: true }); + setGlobalMcpDisabledState(testServer, false); + }); + + afterEach(() => { + rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + setGlobalMcpDisabledState(testServer, false); + }); + + it('should default to not disabled globally', () => { + expect(getGlobalMcpDisabledState(testServer)).toBe(false); + }); + + it('should persist global disabled state', () => { + setGlobalMcpDisabledState(testServer, true); + expect(getGlobalMcpDisabledState(testServer)).toBe(true); + }); + + it('should return undefined when project has no config', () => { + expect(getProjectMcpDisabledState(TEST_PROJECT_DIR, testServer)).toBe(undefined); + }); + + it('should persist project-level disabled state', () => { + setProjectMcpDisabledState(TEST_PROJECT_DIR, testServer, true); + expect(getProjectMcpDisabledState(TEST_PROJECT_DIR, testServer)).toBe(true); + }); + + it('should reset project-level disabled state', () => { + setProjectMcpDisabledState(TEST_PROJECT_DIR, testServer, true); + resetProjectMcpDisabledState(TEST_PROJECT_DIR, testServer); + expect(getProjectMcpDisabledState(TEST_PROJECT_DIR, testServer)).toBe(undefined); + }); + + it('resolveMcpDisabled should use project-level when set', () => { + setGlobalMcpDisabledState(testServer, false); + setProjectMcpDisabledState(TEST_PROJECT_DIR, testServer, true); + expect(resolveMcpDisabled(TEST_PROJECT_DIR, testServer)).toBe(true); + }); + + it('resolveMcpDisabled should fall back to global when project not set', () => { + setGlobalMcpDisabledState(testServer, true); + expect(resolveMcpDisabled(TEST_PROJECT_DIR, testServer)).toBe(true); + }); + + it('resolveMcpDisabled should use project-level enabled over global disabled', () => { + setGlobalMcpDisabledState(testServer, true); + setProjectMcpDisabledState(TEST_PROJECT_DIR, testServer, false); + expect(resolveMcpDisabled(TEST_PROJECT_DIR, testServer)).toBe(false); + }); +}); diff --git a/packages/codingcode/test/mcp/service.test.ts b/packages/codingcode/test/mcp/service.test.ts index 0f71941..44bee26 100644 --- a/packages/codingcode/test/mcp/service.test.ts +++ b/packages/codingcode/test/mcp/service.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Effect, Layer } from 'effect'; import { McpService } from '../../src/mcp/index.js'; import { HookService } from '../../src/hooks/registry.js'; @@ -35,7 +35,8 @@ vi.mock('../../src/mcp/client.js', () => { // Mock loadMcpConfig vi.mock('../../src/mcp/config.js', () => ({ - loadMcpConfig: vi.fn(() => []), + resolveMcpConfig: vi.fn(() => []), + resolveMcpDisabled: vi.fn(() => false), })); function makeHookLayer() { @@ -67,8 +68,8 @@ describe('McpService granular methods', () => { beforeEach(async () => { mockConfigs = []; - const { loadMcpConfig } = await import('../../src/mcp/config.js'); - (loadMcpConfig as any).mockImplementation(() => mockConfigs); + const { resolveMcpConfig } = await import('../../src/mcp/config.js'); + (resolveMcpConfig as any).mockImplementation(() => mockConfigs); }); it('connectServers connects only specified servers', async () => { diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index b6ada2d..3639ce9 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { Effect, Layer } from 'effect'; import { sendMessage } from '../src/agent/agent.js'; import { SessionService } from '../src/session/store.js'; @@ -194,6 +194,7 @@ const { HookLayer } = await import('../src/layer.js'); const MockMcpLayer = Layer.succeed(McpService, { syncConnections: (_: string) => Effect.void, status: (_: string) => Effect.succeed([]), + listProjectMcpTools: (_: string) => [], } as any); const { ProjectRuntimeService } = await import('../src/runtime/project-runtime.js'); diff --git a/packages/codingcode/test/server/handler.test.ts b/packages/codingcode/test/server/handler.test.ts index 8fcb289..e828573 100644 --- a/packages/codingcode/test/server/handler.test.ts +++ b/packages/codingcode/test/server/handler.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { Effect, Layer } from 'effect'; import { sseHandler } from '../../src/server/handler.js'; import { sendMessage } from '../../src/agent/agent.js'; @@ -218,6 +218,7 @@ const MockMcpLayer = Layer.succeed(McpService, { getServerToolNames: () => [], disconnectAll: () => Effect.void, status: () => Effect.succeed([]), + listProjectMcpTools: () => [], } as any); const { ProjectRuntimeService } = await import('../../src/runtime/project-runtime.js'); diff --git a/packages/codingcode/test/server/settings-routes.test.ts b/packages/codingcode/test/server/settings-routes.test.ts index 42be4cf..d47274e 100644 --- a/packages/codingcode/test/server/settings-routes.test.ts +++ b/packages/codingcode/test/server/settings-routes.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { settingsRouter } from '../../src/server/routes/settings.js'; vi.mock('../../src/memory/config.js', () => ({ @@ -25,9 +25,17 @@ vi.mock('../../src/memory/index.js', () => { }); vi.mock('../../src/subagent/registry.js', () => ({ - EXPLORE_PROFILE: { name: 'explore', description: 'Explore', tools: ['read_file'] }, - isAgentDisabledState: vi.fn().mockReturnValue(false), - setAgentDisabledState: vi.fn(), + EXPLORE_PROFILE: { name: 'explore', description: 'Explore', tools: ['read_file'], readonly: true, maxSteps: 30 }, + resolveSubagentEnabled: vi.fn().mockReturnValue(true), + getProjectSubagentEnabledState: vi.fn().mockReturnValue(undefined), + setProjectSubagentEnabledState: vi.fn(), + resetProjectSubagentEnabledState: vi.fn(), + getGlobalAgentDisabledState: vi.fn().mockReturnValue(false), + setGlobalAgentDisabledState: vi.fn(), + getProjectAgentDisabledState: vi.fn().mockReturnValue(undefined), + setProjectAgentDisabledState: vi.fn(), + resetProjectAgentDisabledState: vi.fn(), + resolveAgentDisabled: vi.fn().mockReturnValue(false), getSubagentEnabledState: vi.fn().mockReturnValue(true), setSubagentEnabledState: vi.fn(), })); @@ -35,6 +43,14 @@ vi.mock('../../src/subagent/registry.js', () => ({ vi.mock('../../src/mcp/config.js', () => ({ loadMcpConfig: vi.fn().mockReturnValue([]), writeMcpConfig: vi.fn(), + loadGlobalMcpConfig: vi.fn().mockReturnValue([]), + writeGlobalMcpConfig: vi.fn(), + resolveMcpConfig: vi.fn().mockReturnValue([]), + resolveMcpDisabled: vi.fn().mockReturnValue(false), + getGlobalMcpDisabledState: vi.fn().mockReturnValue(false), + setGlobalMcpDisabledState: vi.fn(), + setProjectMcpDisabledState: vi.fn(), + resetProjectMcpDisabledState: vi.fn(), })); vi.mock('../../src/subagent/loader.js', () => ({ @@ -42,19 +58,33 @@ vi.mock('../../src/subagent/loader.js', () => ({ writeAgentProfile: vi.fn(), updateAgentProfile: vi.fn(), deleteAgentProfile: vi.fn(), + loadGlobalAgentProfiles: vi.fn().mockReturnValue([]), + writeGlobalAgentProfile: vi.fn(), + updateGlobalAgentProfile: vi.fn(), + deleteGlobalAgentProfile: vi.fn(), })); vi.mock('../../src/hooks/config.js', () => ({ loadHookConfigs: vi.fn().mockReturnValue([]), writeHookConfigs: vi.fn(), + loadGlobalHookConfigs: vi.fn().mockReturnValue([]), + writeGlobalHookConfigs: vi.fn(), + resolveHookConfigs: vi.fn().mockReturnValue([]), + resolveHookDisabled: vi.fn().mockReturnValue(false), + setGlobalHookDisabledState: vi.fn(), + setProjectHookDisabledState: vi.fn(), + resetProjectHookDisabledState: vi.fn(), })); vi.mock('../../src/hooks/executor.js', () => ({ setHookRuntimeEnabled: vi.fn(), })); -vi.mock('../../src/mcp/index.js', () => ({ - McpService: {} as any, +vi.mock('../../src/skills/config.js', () => ({ + setGlobalSkillDisabledState: vi.fn(), + setProjectSkillDisabledState: vi.fn(), + discoverGlobalSkillDirs: vi.fn().mockReturnValue([]), + discoverProjectSkillDirs: vi.fn().mockReturnValue([]), })); vi.mock('../../src/skills/index.js', () => ({ @@ -65,6 +95,21 @@ vi.mock('../../src/core/workspace.js', () => ({ resolveWorkspaceCwd: vi.fn((cwd?: string) => cwd ?? '/default'), })); +vi.mock('../../src/core/error.js', () => ({ + AlreadyExistsError: class AlreadyExistsError extends Error { + constructor(msg: string) { super(msg); this.name = 'AlreadyExistsError'; } + }, + NotFoundError: class NotFoundError extends Error { + constructor(msg: string) { super(msg); this.name = 'NotFoundError'; } + }, +})); + +vi.mock('../../src/server/util.js', () => ({ + runWithLayer: vi.fn().mockResolvedValue({ ok: true, value: [] }), + errorResponse: vi.fn().mockReturnValue({ status: 500, body: { error: 'test' } }), +})); + +// ---- Memory ---- describe('GET /memory/config', () => { it('returns memory config with types', async () => { const res = await settingsRouter.request('/memory/config'); @@ -123,24 +168,350 @@ describe('PUT /memory/extra-type/:name', () => { }); }); +// ---- Agents ---- +describe('GET /agents', () => { + it('returns global agents with source and disabled fields', async () => { + const { loadGlobalAgentProfiles } = await import('../../src/subagent/loader.js'); + vi.mocked(loadGlobalAgentProfiles).mockReturnValue([ + { name: 'my-agent', description: 'Test', tools: ['read_file'] }, + ]); + const res = await settingsRouter.request('/agents?cwd=global'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body).toHaveLength(2); // EXPLORE + my-agent + // EXPLORE is builtin + expect(body[0].name).toBe('explore'); + expect(body[0].source).toBe('builtin'); + expect(body[0].disabled).toBe(false); + // my-agent is global + expect(body[1].name).toBe('my-agent'); + expect(body[1].source).toBe('global'); + expect(body[1].disabled).toBe(false); + }); + + it('returns project agents with merged view', async () => { + const { loadGlobalAgentProfiles, loadAgentProfiles } = await import('../../src/subagent/loader.js'); + const { getProjectAgentDisabledState, resolveAgentDisabled } = await import('../../src/subagent/registry.js'); + vi.mocked(loadGlobalAgentProfiles).mockReturnValue([ + { name: 'global-agent', description: 'Global', tools: ['read_file'] }, + ]); + vi.mocked(loadAgentProfiles).mockReturnValue([ + { name: 'project-agent', description: 'Project', tools: ['write_file'] }, + ]); + vi.mocked(getProjectAgentDisabledState).mockReturnValue(undefined); + vi.mocked(resolveAgentDisabled).mockReturnValue(false); + const res = await settingsRouter.request('/agents?cwd=/my-project'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + // EXPLORE + global-agent + project-agent + expect(body).toHaveLength(3); + expect(body[0].source).toBe('builtin'); + expect(body[1].source).toBe('global'); + expect(body[2].source).toBe('project'); + }); +}); + +describe('POST /agents/:name/disabled', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls setGlobalAgentDisabledState for global cwd', async () => { + const { setGlobalAgentDisabledState } = await import('../../src/subagent/registry.js'); + const res = await settingsRouter.request('/agents/my-agent/disabled?cwd=global', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ disabled: true }), + }); + expect(res.status).toBe(200); + expect(setGlobalAgentDisabledState).toHaveBeenCalledWith('my-agent', true); + }); + + it('calls setProjectAgentDisabledState for project cwd', async () => { + const { setProjectAgentDisabledState } = await import('../../src/subagent/registry.js'); + const res = await settingsRouter.request('/agents/my-agent/disabled?cwd=/my-project', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ disabled: true }), + }); + expect(res.status).toBe(200); + expect(setProjectAgentDisabledState).toHaveBeenCalledWith('/my-project', 'my-agent', true); + }); +}); + +describe('POST /agents/:name/disabled/reset', () => { + it('calls resetProjectAgentDisabledState', async () => { + const { resetProjectAgentDisabledState } = await import('../../src/subagent/registry.js'); + const res = await settingsRouter.request('/agents/my-agent/disabled/reset?cwd=/my-project', { + method: 'POST', + }); + expect(res.status).toBe(200); + expect(resetProjectAgentDisabledState).toHaveBeenCalledWith('/my-project', 'my-agent'); + }); +}); + +// ---- Subagent enabled ---- describe('GET /subagent/enabled', () => { - it('returns enabled state', async () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns global enabled state with source=global when no cwd', async () => { + const { getSubagentEnabledState } = await import('../../src/subagent/registry.js'); + vi.mocked(getSubagentEnabledState).mockReturnValue(true); const res = await settingsRouter.request('/subagent/enabled'); expect(res.status).toBe(200); - const body = (await res.json()) as { enabled: boolean }; + const body = (await res.json()) as { enabled: boolean; source: string }; expect(body.enabled).toBe(true); + expect(body.source).toBe('global'); + }); + + it('returns project enabled state with source=project when project override exists', async () => { + const { getProjectSubagentEnabledState, resolveSubagentEnabled } = await import('../../src/subagent/registry.js'); + vi.mocked(getProjectSubagentEnabledState).mockReturnValue(false); + vi.mocked(resolveSubagentEnabled).mockReturnValue(false); + const res = await settingsRouter.request('/subagent/enabled?cwd=/my-project'); + expect(res.status).toBe(200); + const body = (await res.json()) as { enabled: boolean; source: string }; + expect(body.enabled).toBe(false); + expect(body.source).toBe('project'); + }); + + it('returns source=global when no project override', async () => { + const { getProjectSubagentEnabledState, resolveSubagentEnabled } = await import('../../src/subagent/registry.js'); + vi.mocked(getProjectSubagentEnabledState).mockReturnValue(undefined); + vi.mocked(resolveSubagentEnabled).mockReturnValue(true); + const res = await settingsRouter.request('/subagent/enabled?cwd=/my-project'); + expect(res.status).toBe(200); + const body = (await res.json()) as { enabled: boolean; source: string }; + expect(body.source).toBe('global'); }); }); describe('POST /subagent/enabled', () => { - it('updates enabled state', async () => { - const res = await settingsRouter.request('/subagent/enabled', { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls setSubagentEnabledState for global cwd', async () => { + const { setSubagentEnabledState } = await import('../../src/subagent/registry.js'); + const res = await settingsRouter.request('/subagent/enabled?cwd=global', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false }), }); expect(res.status).toBe(200); - const body = (await res.json()) as { ok: boolean }; - expect(body.ok).toBe(true); + expect(setSubagentEnabledState).toHaveBeenCalledWith(false); + }); + + it('calls setProjectSubagentEnabledState for project cwd', async () => { + const { setProjectSubagentEnabledState } = await import('../../src/subagent/registry.js'); + const res = await settingsRouter.request('/subagent/enabled?cwd=/my-project', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: false }), + }); + expect(res.status).toBe(200); + expect(setProjectSubagentEnabledState).toHaveBeenCalledWith('/my-project', false); + }); +}); + +describe('POST /subagent/enabled/reset', () => { + it('calls resetProjectSubagentEnabledState', async () => { + const { resetProjectSubagentEnabledState } = await import('../../src/subagent/registry.js'); + const res = await settingsRouter.request('/subagent/enabled/reset?cwd=/my-project', { + method: 'POST', + }); + expect(res.status).toBe(200); + expect(resetProjectSubagentEnabledState).toHaveBeenCalledWith('/my-project'); + }); +}); + +// ---- MCP ---- +describe('GET /mcp', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns global MCP servers with source and disabled', async () => { + const { loadGlobalMcpConfig, getGlobalMcpDisabledState } = await import('../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([ + { name: 'server1', command: 'npx' }, + ]); + vi.mocked(getGlobalMcpDisabledState).mockReturnValue(false); + const res = await settingsRouter.request('/mcp?cwd=global'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body).toHaveLength(1); + expect(body[0].source).toBe('global'); + expect(body[0].disabled).toBe(false); + }); + + it('returns project MCP servers with merged view', async () => { + const { loadGlobalMcpConfig, loadMcpConfig, resolveMcpConfig, resolveMcpDisabled } = await import('../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([ + { name: 'global-srv', command: 'npx' }, + ]); + vi.mocked(loadMcpConfig).mockReturnValue([ + { name: 'project-srv', command: 'node' }, + ]); + vi.mocked(resolveMcpConfig).mockReturnValue([ + { name: 'global-srv', command: 'npx' }, + { name: 'project-srv', command: 'node' }, + ]); + vi.mocked(resolveMcpDisabled).mockReturnValue(false); + const res = await settingsRouter.request('/mcp?cwd=/my-project'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body).toHaveLength(2); + expect(body[0].source).toBe('global'); + expect(body[1].source).toBe('project'); + }); +}); + +describe('POST /mcp/:name/disabled', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls setGlobalMcpDisabledState for global cwd', async () => { + const { setGlobalMcpDisabledState } = await import('../../src/mcp/config.js'); + const res = await settingsRouter.request('/mcp/srv1/disabled?cwd=global', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ disabled: true }), + }); + expect(res.status).toBe(200); + expect(setGlobalMcpDisabledState).toHaveBeenCalledWith('srv1', true); + }); + + it('calls setProjectMcpDisabledState for project cwd', async () => { + const { setProjectMcpDisabledState } = await import('../../src/mcp/config.js'); + const res = await settingsRouter.request('/mcp/srv1/disabled?cwd=/my-project', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ disabled: true }), + }); + expect(res.status).toBe(200); + expect(setProjectMcpDisabledState).toHaveBeenCalledWith('/my-project', 'srv1', true); + }); +}); + +describe('POST /mcp/:name/disabled/reset', () => { + it('calls resetProjectMcpDisabledState', async () => { + const { resetProjectMcpDisabledState } = await import('../../src/mcp/config.js'); + const res = await settingsRouter.request('/mcp/srv1/disabled/reset?cwd=/my-project', { + method: 'POST', + }); + expect(res.status).toBe(200); + expect(resetProjectMcpDisabledState).toHaveBeenCalledWith('/my-project', 'srv1'); + }); +}); + +// ---- Hooks ---- +describe('GET /hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns global hooks with source field', async () => { + const { loadGlobalHookConfigs } = await import('../../src/hooks/config.js'); + vi.mocked(loadGlobalHookConfigs).mockReturnValue([ + { name: 'hook1', point: 'pre_tool_use', type: 'observer', command: 'echo', enabled: true }, + ]); + const res = await settingsRouter.request('/hooks?cwd=global'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body).toHaveLength(1); + expect(body[0].source).toBe('global'); + }); + + it('returns project hooks with merged view', async () => { + const { loadGlobalHookConfigs, loadHookConfigs, resolveHookConfigs, resolveHookDisabled } = await import('../../src/hooks/config.js'); + vi.mocked(loadGlobalHookConfigs).mockReturnValue([ + { name: 'global-hook', point: 'pre_tool_use', type: 'observer', command: 'echo', enabled: true }, + ]); + vi.mocked(loadHookConfigs).mockReturnValue([ + { name: 'project-hook', point: 'post_tool_use', type: 'decision', command: 'sh', enabled: true }, + ]); + vi.mocked(resolveHookConfigs).mockReturnValue([ + { name: 'global-hook', point: 'pre_tool_use', type: 'observer', command: 'echo', enabled: true }, + { name: 'project-hook', point: 'post_tool_use', type: 'decision', command: 'sh', enabled: true }, + ]); + vi.mocked(resolveHookDisabled).mockReturnValue(false); + const res = await settingsRouter.request('/hooks?cwd=/my-project'); + expect(res.status).toBe(200); + const body = (await res.json()) as any[]; + expect(body).toHaveLength(2); + expect(body[0].source).toBe('global'); + expect(body[1].source).toBe('project'); + }); +}); + +describe('POST /hooks/:name/disabled', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls setGlobalHookDisabledState for global cwd', async () => { + const { setGlobalHookDisabledState } = await import('../../src/hooks/config.js'); + const res = await settingsRouter.request('/hooks/hook1/disabled?cwd=global', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ disabled: true }), + }); + expect(res.status).toBe(200); + expect(setGlobalHookDisabledState).toHaveBeenCalledWith('hook1', true); + }); + + it('calls setProjectHookDisabledState for project cwd', async () => { + const { setProjectHookDisabledState } = await import('../../src/hooks/config.js'); + const res = await settingsRouter.request('/hooks/hook1/disabled?cwd=/my-project', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ disabled: true }), + }); + expect(res.status).toBe(200); + expect(setProjectHookDisabledState).toHaveBeenCalledWith('/my-project', 'hook1', true); + }); +}); + +describe('POST /hooks/:name/disabled/reset', () => { + it('calls resetProjectHookDisabledState', async () => { + const { resetProjectHookDisabledState } = await import('../../src/hooks/config.js'); + const res = await settingsRouter.request('/hooks/hook1/disabled/reset?cwd=/my-project', { + method: 'POST', + }); + expect(res.status).toBe(200); + expect(resetProjectHookDisabledState).toHaveBeenCalledWith('/my-project', 'hook1'); + }); +}); + +// ---- Skills ---- +describe('POST /skills', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls setGlobalSkillDisabledState for global cwd', async () => { + const { setGlobalSkillDisabledState } = await import('../../src/skills/config.js'); + const res = await settingsRouter.request('/skills?cwd=global', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'my-skill', enabled: false }), + }); + expect(res.status).toBe(200); + expect(setGlobalSkillDisabledState).toHaveBeenCalledWith('my-skill', true); + }); + + it('calls setProjectSkillDisabledState for project cwd', async () => { + const { setProjectSkillDisabledState } = await import('../../src/skills/config.js'); + const res = await settingsRouter.request('/skills?cwd=/my-project', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'my-skill', enabled: false }), + }); + expect(res.status).toBe(200); + expect(setProjectSkillDisabledState).toHaveBeenCalledWith('/my-project', 'my-skill', true); }); }); diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 7506bba..d67c68b 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -1,4 +1,4 @@ -import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest'; +import { expect, it, describe, vi, beforeEach, afterEach } from 'vitest'; import { Effect } from 'effect'; import { createDispatchAgentTool } from '../../src/tools/domains/subagent/dispatch'; import { EXPLORE_PROFILE } from '../../src/subagent/registry'; @@ -85,15 +85,15 @@ const mockSession = { const mockRuntime: ProjectRuntimeService = { _tag: 'ProjectRuntime' as const, prepareProject: (_p: string) => Effect.void, - resolveMainAgentProfile: (_p: string, _s: string) => EXPLORE_PROFILE, + resolveMainAgentProfile: (_p: string, _s: string): AgentProfile | undefined => EXPLORE_PROFILE, resolveSubagentProfile: (_p: string, name: string) => { if (name === 'explore') return EXPLORE_PROFILE; return undefined; }, listAgentProfiles: (_p: string) => [EXPLORE_PROFILE], - getToolPolicy: (profile: AgentProfile) => ({ - allowedTools: profile.tools ? new Set(profile.tools) : undefined, - allowedMcpServers: profile.mcpServers ? new Set(profile.mcpServers) : undefined, + getToolPolicy: (profile: AgentProfile | undefined) => ({ + allowedTools: profile?.tools ? new Set(profile.tools) : undefined, + allowedMcpServers: profile?.mcpServers ? new Set(profile.mcpServers) : undefined, allowToolSearch: true, allowDeferredTools: false, }), diff --git a/packages/codingcode/test/subagent/registry.test.ts b/packages/codingcode/test/subagent/registry.test.ts index cd2586b..340781c 100644 --- a/packages/codingcode/test/subagent/registry.test.ts +++ b/packages/codingcode/test/subagent/registry.test.ts @@ -1,4 +1,4 @@ -import { expect, it, describe } from 'vitest'; +import { expect, it, describe } from 'vitest'; import { Effect } from 'effect'; import { SubagentRegistry, EXPLORE_PROFILE } from '../../src/subagent/registry'; import { SubagentRegistryLayer } from '../../src/layer'; @@ -112,75 +112,4 @@ describe('SubagentRegistry', () => { }) ); }); - - it('should default to enabled=true', async () => { - await Effect.runPromise( - testEffect((registry) => { - expect(registry.isEnabled()).toBe(true); - }) - ); - }); - - it('should allow disabling via setEnabled(false)', async () => { - await Effect.runPromise( - testEffect((registry) => { - registry.setEnabled(false); - expect(registry.isEnabled()).toBe(false); - }) - ); - }); - - it('should allow re-enabling via setEnabled(true)', async () => { - await Effect.runPromise( - testEffect((registry) => { - registry.setEnabled(false); - registry.setEnabled(true); - expect(registry.isEnabled()).toBe(true); - }) - ); - }); - - it('should restore enabled=true after reset()', async () => { - await Effect.runPromise( - testEffect((registry) => { - registry.setEnabled(false); - expect(registry.isEnabled()).toBe(false); - registry.reset(); - expect(registry.isEnabled()).toBe(true); - }) - ); - }); - - describe('per-agent disable', () => { - it('should default to not disabled', async () => { - await Effect.runPromise( - testEffect((registry) => { - expect(registry.isAgentDisabled('any-agent')).toBe(false); - }) - ); - }); - - it('should disable and re-enable a specific agent', async () => { - await Effect.runPromise( - testEffect((registry) => { - registry.register({ name: 'test', description: 'Test', systemPrompt: 'You are test.' }); - registry.disableAgent('test'); - expect(registry.isAgentDisabled('test')).toBe(true); - registry.enableAgent('test'); - expect(registry.isAgentDisabled('test')).toBe(false); - }) - ); - }); - - it('should clear disabled state on reset', async () => { - await Effect.runPromise( - testEffect((registry) => { - registry.register({ name: 'test', description: 'Test', systemPrompt: 'You are test.' }); - registry.disableAgent('test'); - registry.reset(); - expect(registry.isAgentDisabled('test')).toBe(false); - }) - ); - }); - }); }); diff --git a/packages/codingcode/test/subagent/switch.test.ts b/packages/codingcode/test/subagent/switch.test.ts new file mode 100644 index 0000000..5b98050 --- /dev/null +++ b/packages/codingcode/test/subagent/switch.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { + getSubagentEnabledState, + setSubagentEnabledState, + getProjectSubagentEnabledState, + setProjectSubagentEnabledState, + resetProjectSubagentEnabledState, + resolveSubagentEnabled, + getGlobalAgentDisabledState, + setGlobalAgentDisabledState, + getProjectAgentDisabledState, + setProjectAgentDisabledState, + resetProjectAgentDisabledState, + resolveAgentDisabled, +} from '../../src/subagent/registry.js'; +import { buildSystemPrompt } from '../../src/agent/prompt.js'; +import type { AgentProfile } from '../../src/subagent/registry.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// 临时项目目录用于测试 +const TMP_PROJECT = join(__dirname, '__tmp_project_test__'); + +describe('Subagent switch', () => { + describe('Global enabled state', () => { + afterEach(() => { + setSubagentEnabledState(true); + }); + + it('should default to enabled', () => { + expect(getSubagentEnabledState()).toBe(true); + }); + + it('should persist disabled state', () => { + setSubagentEnabledState(false); + expect(getSubagentEnabledState()).toBe(false); + }); + + it('should persist enabled state', () => { + setSubagentEnabledState(false); + setSubagentEnabledState(true); + expect(getSubagentEnabledState()).toBe(true); + }); + }); + + describe('System prompt filtering', () => { + it('should filter out disabled agents from system prompt', () => { + const profiles: AgentProfile[] = [ + { name: 'enabled-agent', description: 'I am enabled', disabled: false }, + { name: 'disabled-agent', description: 'I am disabled', disabled: true }, + ]; + + const prompt = buildSystemPrompt({ agentProfiles: profiles }); + + expect(prompt).toContain('enabled-agent'); + expect(prompt).not.toContain('disabled-agent'); + }); + + it('should not inject Available Subagents when all agents are disabled', () => { + const profiles: AgentProfile[] = [ + { name: 'disabled-agent', description: 'I am disabled', disabled: true }, + ]; + + const prompt = buildSystemPrompt({ agentProfiles: profiles }); + + expect(prompt).not.toContain('Available Subagents'); + }); + + it('should inject Available Subagents when at least one agent is enabled', () => { + const profiles: AgentProfile[] = [ + { name: 'enabled-agent', description: 'I am enabled', disabled: false }, + { name: 'disabled-agent', description: 'I am disabled', disabled: true }, + ]; + + const prompt = buildSystemPrompt({ agentProfiles: profiles }); + + expect(prompt).toContain('Available Subagents'); + }); + + it('should not inject Available Subagents when no profiles provided', () => { + const prompt = buildSystemPrompt({}); + + expect(prompt).not.toContain('Available Subagents'); + }); + + it('should not inject Available Subagents when subagent switch is off (empty profiles)', () => { + // Simulates agent.ts logic: when resolveSubagentEnabled is false, agentProfiles = [] + const prompt = buildSystemPrompt({ agentProfiles: [] }); + + expect(prompt).not.toContain('Available Subagents'); + }); + + it('should filter out resolveAgentDisabled agents from system prompt', () => { + // Simulates agent.ts logic: allAgentProfiles.filter(p => !resolveAgentDisabled(projectPath, p.name)) + const allProfiles: AgentProfile[] = [ + { name: 'agent-a', description: 'Agent A' }, + { name: 'agent-b', description: 'Agent B' }, + ]; + // Simulate agent-b being disabled via resolveAgentDisabled + const filteredProfiles = allProfiles.filter((p) => p.name !== 'agent-b'); + + const prompt = buildSystemPrompt({ agentProfiles: filteredProfiles }); + + expect(prompt).toContain('agent-a'); + expect(prompt).not.toContain('agent-b'); + }); + }); +}); + +describe('Project-level subagent enabled state', () => { + beforeEach(() => { + // 创建临时项目目录 + mkdirSync(join(TMP_PROJECT, '.codingcode'), { recursive: true }); + // 确保全局开关为 true + setSubagentEnabledState(true); + }); + + afterEach(() => { + // 清理临时目录 + rmSync(TMP_PROJECT, { recursive: true, force: true }); + setSubagentEnabledState(true); + }); + + it('should return undefined when project has no config', () => { + expect(getProjectSubagentEnabledState(TMP_PROJECT)).toBe(undefined); + }); + + it('should persist project-level enabled state', () => { + setProjectSubagentEnabledState(TMP_PROJECT, false); + expect(getProjectSubagentEnabledState(TMP_PROJECT)).toBe(false); + }); + + it('should persist project-level enabled=true state', () => { + setProjectSubagentEnabledState(TMP_PROJECT, false); + setProjectSubagentEnabledState(TMP_PROJECT, true); + expect(getProjectSubagentEnabledState(TMP_PROJECT)).toBe(true); + }); + + it('should reset project-level state to undefined', () => { + setProjectSubagentEnabledState(TMP_PROJECT, false); + resetProjectSubagentEnabledState(TMP_PROJECT); + expect(getProjectSubagentEnabledState(TMP_PROJECT)).toBe(undefined); + }); + + it('resolveSubagentEnabled should use project-level when set', () => { + setSubagentEnabledState(true); // 全局开启 + setProjectSubagentEnabledState(TMP_PROJECT, false); // 项目级关闭 + expect(resolveSubagentEnabled(TMP_PROJECT)).toBe(false); + }); + + it('resolveSubagentEnabled should fall back to global when project not set', () => { + setSubagentEnabledState(false); // 全局关闭 + // 项目级未设置 + expect(resolveSubagentEnabled(TMP_PROJECT)).toBe(false); + }); + + it('resolveSubagentEnabled should use global when project config does not exist', () => { + setSubagentEnabledState(true); + const noConfigProject = join(__dirname, '__no_config__'); + try { + mkdirSync(noConfigProject, { recursive: true }); + expect(resolveSubagentEnabled(noConfigProject)).toBe(true); + } finally { + rmSync(noConfigProject, { recursive: true, force: true }); + } + }); +}); + +describe('Global agent disabled state', () => { + const testAgent = '__test_global_agent__'; + + afterEach(() => { + // 清理:重置全局 disabled 状态 + setGlobalAgentDisabledState(testAgent, false); + }); + + it('should default to not disabled', () => { + expect(getGlobalAgentDisabledState(testAgent)).toBe(false); + }); + + it('should persist disabled state', () => { + setGlobalAgentDisabledState(testAgent, true); + expect(getGlobalAgentDisabledState(testAgent)).toBe(true); + }); + + it('should persist re-enabled state', () => { + setGlobalAgentDisabledState(testAgent, true); + setGlobalAgentDisabledState(testAgent, false); + expect(getGlobalAgentDisabledState(testAgent)).toBe(false); + }); +}); + +describe('Project-level agent disabled state', () => { + const testAgent = '__test_project_agent__'; + + beforeEach(() => { + mkdirSync(join(TMP_PROJECT, '.codingcode'), { recursive: true }); + setGlobalAgentDisabledState(testAgent, false); + }); + + afterEach(() => { + rmSync(TMP_PROJECT, { recursive: true, force: true }); + setGlobalAgentDisabledState(testAgent, false); + }); + + it('should return undefined when project has no config', () => { + expect(getProjectAgentDisabledState(TMP_PROJECT, testAgent)).toBe(undefined); + }); + + it('should persist project-level disabled state', () => { + setProjectAgentDisabledState(TMP_PROJECT, testAgent, true); + expect(getProjectAgentDisabledState(TMP_PROJECT, testAgent)).toBe(true); + }); + + it('should reset project-level disabled state', () => { + setProjectAgentDisabledState(TMP_PROJECT, testAgent, true); + resetProjectAgentDisabledState(TMP_PROJECT, testAgent); + expect(getProjectAgentDisabledState(TMP_PROJECT, testAgent)).toBe(undefined); + }); + + it('resolveAgentDisabled should use project-level when set', () => { + setGlobalAgentDisabledState(testAgent, false); // 全局未禁用 + setProjectAgentDisabledState(TMP_PROJECT, testAgent, true); // 项目级禁用 + expect(resolveAgentDisabled(TMP_PROJECT, testAgent)).toBe(true); + }); + + it('resolveAgentDisabled should fall back to global when project not set', () => { + setGlobalAgentDisabledState(testAgent, true); // 全局禁用 + // 项目级未设置 + expect(resolveAgentDisabled(TMP_PROJECT, testAgent)).toBe(true); + }); + + it('resolveAgentDisabled should use project-level enabled over global disabled', () => { + setGlobalAgentDisabledState(testAgent, true); // 全局禁用 + setProjectAgentDisabledState(TMP_PROJECT, testAgent, false); // 项目级启用 + expect(resolveAgentDisabled(TMP_PROJECT, testAgent)).toBe(false); + }); + + it('resolveAgentDisabled should use global when project config does not exist', () => { + setGlobalAgentDisabledState(testAgent, false); + const noConfigProject = join(__dirname, '__no_config_agent__'); + try { + mkdirSync(noConfigProject, { recursive: true }); + expect(resolveAgentDisabled(noConfigProject, testAgent)).toBe(false); + } finally { + rmSync(noConfigProject, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/desktop/src/agent/AgentSidebar.tsx b/packages/desktop/src/agent/AgentSidebar.tsx index 8923e02..252a67e 100644 --- a/packages/desktop/src/agent/AgentSidebar.tsx +++ b/packages/desktop/src/agent/AgentSidebar.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react'; -import { ChevronLeft, ChevronRight, Plus, Search, Zap, Settings } from 'lucide-react'; +import { Plus, Search, Zap, Settings } from 'lucide-react'; import { useGlobalStore } from '../stores/global.store'; import { api } from '../lib/api'; @@ -23,7 +23,7 @@ export default function AgentSidebar() { const rootPath = useGlobalStore((s) => s.workspace.rootPath); const workspace = useGlobalStore((s) => s.workspace); const setCurrentThread = useGlobalStore((s) => s.setCurrentThread); - const toggleSidebar = useGlobalStore((s) => s.toggleSidebar); + const setView = useGlobalStore((s) => s.setView); // Subscribe to raw threads, derive list with useMemo for stable reference const rawThreads = useGlobalStore((s) => s.agent.threads); @@ -68,34 +68,23 @@ export default function AgentSidebar() { const projectName = currentProject?.name || workspace.name; if (sidebarCollapsed) { - return ( -
- -
- ); + return null; } return (
- {/* 顶部栏:项目名 + 收起按钮 */} + {/* 顶部栏:项目名 + 项目级设置按钮 */}
{projectName || '项目'}
@@ -186,20 +175,6 @@ export default function AgentSidebar() {
暂无对话
)}
- -
- - {/* 底部 */} -
- -
); } diff --git a/packages/desktop/src/agent/ProjectStrip.tsx b/packages/desktop/src/agent/ProjectStrip.tsx index fd96eee..551d034 100644 --- a/packages/desktop/src/agent/ProjectStrip.tsx +++ b/packages/desktop/src/agent/ProjectStrip.tsx @@ -1,4 +1,5 @@ import { useState, useMemo } from 'react'; +import { Settings, ChevronLeft, ChevronRight } from 'lucide-react'; import { useGlobalStore } from '../stores/global.store'; import { API_BASE, api } from '../lib/api'; import type { Project, Thread } from '@shared/types'; @@ -133,6 +134,8 @@ export default function ProjectStrip() { const switchProject = useGlobalStore((s) => s.switchProject); const addProject = useGlobalStore((s) => s.addProject); const setCurrentThread = useGlobalStore((s) => s.setCurrentThread); + const setView = useGlobalStore((s) => s.setView); + const toggleSidebar = useGlobalStore((s) => s.toggleSidebar); const [hoveredId, setHoveredId] = useState(null); @@ -231,6 +234,31 @@ export default function ProjectStrip() { > + + + {/* Spacer to push settings and collapse to bottom */} +
+ + {/* Global settings button */} + + + {/* Collapse/expand sidebar button */} +
); } diff --git a/packages/desktop/src/layouts/AgentLayout.tsx b/packages/desktop/src/layouts/AgentLayout.tsx index 3124c65..1103a0b 100644 --- a/packages/desktop/src/layouts/AgentLayout.tsx +++ b/packages/desktop/src/layouts/AgentLayout.tsx @@ -3,23 +3,26 @@ import { useGlobalStore } from '../stores/global.store'; import ProjectStrip from '../agent/ProjectStrip'; import AgentSidebar from '../agent/AgentSidebar'; import AgentWorkspace from '../agent/AgentWorkspace'; -import SettingsPage from '../settings/SettingsPage'; +import GlobalSettingsPage from '../settings/GlobalSettingsPage'; +import ProjectSettingsPage from '../settings/ProjectSettingsPage'; export default function AgentLayout() { const { sendMessage, abort } = useAgentCore(); const view = useGlobalStore((s) => s.ui.view); + if (view === 'global-settings') { + return ; + } + + if (view === 'project-settings') { + return ; + } + return ( - <> - {view === 'settings' ? ( - - ) : ( -
- - - -
- )} - +
+ + + +
); } diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index 3cf446f..133cb4a 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -92,11 +92,15 @@ export function deleteMemoryExtraType(name: string): Promise { // ---- Settings: MCP ---- export function listMcpServers(cwd?: string): Promise { - return clients.settings.getMcpStatus(); + return clients.settings.getMcpStatus({ cwd: cwd ?? '' }); } -export function setMcpDisabled(name: string, disabled: boolean): Promise { - return clients.settings.setMcpDisabled(name, disabled); +export function setMcpDisabled(name: string, disabled: boolean, cwd?: string): Promise { + return clients.settings.setMcpDisabled({ name, disabled, cwd: cwd ?? '' }); +} + +export function resetMcpDisabled(name: string, cwd: string): Promise { + return clients.settings.resetMcpDisabled({ name, cwd }); } export function createMcpServer( @@ -124,8 +128,12 @@ export function listAgents(cwd?: string): Promise { return clients.settings.listAgents({ cwd: cwd ?? '' }); } -export function setAgentDisabled(name: string, disabled: boolean): Promise { - return clients.settings.setAgentDisabled(name, disabled); +export function setAgentDisabled(name: string, disabled: boolean, cwd?: string): Promise { + return clients.settings.setAgentDisabled({ name, disabled, cwd: cwd ?? '' }); +} + +export function resetAgentDisabled(name: string, cwd: string): Promise { + return clients.settings.resetAgentDisabled({ name, cwd }); } export function createAgent( @@ -149,25 +157,28 @@ export function deleteAgent(cwd: string | undefined, name: string): Promise { - const enabled = await clients.settings.getSubagentEnabled(); - return { enabled }; +export async function getSubagentEnabled(cwd?: string): Promise<{ enabled: boolean; source: string }> { + return clients.settings.getSubagentEnabled({ cwd: cwd ?? '' }); } -export function setSubagentEnabled(enabled: boolean): Promise { - return clients.settings.setSubagentEnabled(enabled); +export function setSubagentEnabled(enabled: boolean, cwd?: string): Promise { + return clients.settings.setSubagentEnabled({ enabled, cwd: cwd ?? '' }); +} + +export function resetSubagentEnabled(cwd: string): Promise { + return clients.settings.resetSubagentEnabled({ cwd }); } // ---- Settings: Skills ---- -export function listSkills(): Promise< +export function listSkills(cwd?: string): Promise< Array<{ name: string; description: string; disabled: boolean }> > { - return clients.settings.listSkills() as any; + return clients.settings.listSkills({ cwd: cwd ?? '' }) as any; } -export function toggleSkill(name: string, enabled: boolean): Promise { - return clients.settings.toggleSkill(name, enabled); +export function toggleSkill(name: string, enabled: boolean, cwd?: string): Promise { + return clients.settings.toggleSkill({ name, enabled, cwd: cwd ?? '' }); } // ---- Settings: Hooks ---- @@ -200,6 +211,10 @@ export function setHookDisabled( return clients.settings.setHookDisabled({ cwd: cwd ?? '', name, disabled }); } +export function resetHookDisabled(name: string, cwd: string): Promise { + return clients.settings.resetHookDisabled({ name, cwd }); +} + // ---- Rollback / Checkpoint ---- export interface CheckpointDiff { diff --git a/packages/desktop/src/settings/GlobalSettingsPage.tsx b/packages/desktop/src/settings/GlobalSettingsPage.tsx new file mode 100644 index 0000000..4cff266 --- /dev/null +++ b/packages/desktop/src/settings/GlobalSettingsPage.tsx @@ -0,0 +1,98 @@ +import { ArrowLeft } from 'lucide-react'; +import { useGlobalStore } from '../stores/global.store'; +import { useState } from 'react'; +import McpPanel from './McpPanel'; +import HooksPanel from './HooksPanel'; +import SubagentsPanel from './SubagentsPanel'; +import SkillPanel from './SkillPanel'; +import MemoryPanel from './MemoryPanel'; + +type Section = 'theme' | 'mcp' | 'hooks' | 'agents' | 'skills' | 'memory'; + +const NAV_ITEMS: { id: Section; label: string }[] = [ + { id: 'theme', label: '主题' }, + { id: 'mcp', label: 'MCP 服务器' }, + { id: 'hooks', label: '钩子' }, + { id: 'agents', label: '子智能体' }, + { id: 'skills', label: 'Skills' }, + { id: 'memory', label: '记忆模式' }, +]; + +const THEMES = [ + { id: 'dark' as const, label: '深色' }, + { id: 'light' as const, label: '浅色' }, + { id: 'paper' as const, label: '纸黄' }, +]; + +export default function GlobalSettingsPage() { + const setView = useGlobalStore((s) => s.setView); + const [section, setSection] = useState
('theme'); + const theme = useGlobalStore((s) => s.ui.theme); + const setTheme = useGlobalStore((s) => s.setTheme); + + return ( +
+ {/* Header */} +
+ + 全局设置 +
+ +
+ {/* Left sidebar */} + + + {/* Content area */} +
+ {section === 'theme' && ( +
+
主题
+
+ {THEMES.map((t) => ( + + ))} +
+
+ )} + {section === 'mcp' && } + {section === 'hooks' && } + {section === 'agents' && } + {section === 'skills' && } + {section === 'memory' && } +
+
+
+ ); +} diff --git a/packages/desktop/src/settings/HooksPanel.tsx b/packages/desktop/src/settings/HooksPanel.tsx index 4648899..c2b74a6 100644 --- a/packages/desktop/src/settings/HooksPanel.tsx +++ b/packages/desktop/src/settings/HooksPanel.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import Toggle from './Toggle'; import { useGlobalStore } from '../stores/global.store'; -import { listHooks, createHook, updateHook, deleteHook, setHookDisabled } from '../lib/core-api'; +import { listHooks, createHook, updateHook, deleteHook, setHookDisabled, resetHookDisabled } from '../lib/core-api'; interface HookEntry { name: string; @@ -13,6 +13,8 @@ interface HookEntry { env?: Record; priority?: number; enabled: boolean; + source?: 'global' | 'project'; + hasProjectOverride?: boolean; } interface HookGroup { @@ -92,7 +94,7 @@ const EMPTY_FORM: HookForm = { enabled: true, }; -export default function HooksPanel() { +export default function HooksPanel({ global: isGlobal }: { global?: boolean }) { const [hooks, setHooks] = useState([]); const [loading, setLoading] = useState(true); const [isCreating, setIsCreating] = useState(false); @@ -100,11 +102,12 @@ export default function HooksPanel() { const [deletingName, setDeletingName] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const rootPath = useGlobalStore((s) => s.workspace.rootPath); + const cwd = isGlobal ? undefined : rootPath; const load = async () => { setLoading(true); try { - const data = await listHooks(rootPath ?? undefined); + const data = await listHooks(cwd); setHooks(data ?? []); } catch { setHooks([]); @@ -176,9 +179,9 @@ export default function HooksPanel() { try { if (isCreating) { - await createHook(rootPath ?? undefined, hook); + await createHook(cwd, hook); } else if (editingName) { - await updateHook(rootPath ?? undefined, editingName, hook); + await updateHook(cwd, editingName, hook); } cancelForm(); await load(); @@ -190,7 +193,7 @@ export default function HooksPanel() { const confirmDelete = async () => { if (!deletingName) return; try { - await deleteHook(rootPath ?? undefined, deletingName); + await deleteHook(cwd, deletingName); setDeletingName(null); await load(); } catch (e: any) { @@ -297,6 +300,21 @@ export default function HooksPanel() { {h.point} + {h.source === 'global' && ( + + 全局 + + )} + {h.source === 'project' && ( + + 项目 + + )} + {h.hasProjectOverride && ( + + 覆盖全局 + + )} {!h.enabled && ( 已禁用 @@ -344,7 +362,7 @@ export default function HooksPanel() { { - setHookDisabled(rootPath ?? undefined, h.name, !v).catch((e) => { + setHookDisabled(cwd, h.name, !v).catch((e) => { console.error('Failed to set hook disabled:', e); }); setHooks((prev) => diff --git a/packages/desktop/src/settings/McpPanel.tsx b/packages/desktop/src/settings/McpPanel.tsx index 8c849c4..0339827 100644 --- a/packages/desktop/src/settings/McpPanel.tsx +++ b/packages/desktop/src/settings/McpPanel.tsx @@ -4,6 +4,7 @@ import { useGlobalStore } from '../stores/global.store'; import { listMcpServers, setMcpDisabled, + resetMcpDisabled, createMcpServer, updateMcpServer, deleteMcpServer, @@ -15,6 +16,8 @@ interface McpEntry { transport: 'stdio' | 'http'; disabled: boolean; toolCount: number; + source?: 'global' | 'project'; + hasProjectOverride?: boolean; } interface McpForm { @@ -41,7 +44,7 @@ const EMPTY_FORM: McpForm = { autoReconnect: true, }; -export default function McpPanel() { +export default function McpPanel({ global: isGlobal }: { global?: boolean }) { const [servers, setServers] = useState([]); const [loading, setLoading] = useState(true); const [isCreating, setIsCreating] = useState(false); @@ -49,11 +52,12 @@ export default function McpPanel() { const [deletingName, setDeletingName] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const rootPath = useGlobalStore((s) => s.workspace.rootPath); + const cwd = isGlobal ? undefined : rootPath; const load = async () => { setLoading(true); try { - const data = await listMcpServers(); + const data = await listMcpServers(cwd); setServers(data ?? []); } catch { setServers([]); @@ -67,7 +71,7 @@ export default function McpPanel() { }, []); const toggle = async (name: string, disabled: boolean) => { - await setMcpDisabled(name, disabled); + await setMcpDisabled(name, disabled, cwd); setServers((prev) => prev.map((s) => (s.name === name ? { ...s, disabled } : s))); }; @@ -135,10 +139,10 @@ export default function McpPanel() { try { if (isCreating) { - await createMcpServer(rootPath ?? undefined, server); + await createMcpServer(cwd, server); } else if (editingName) { if (editingName !== form.name) { - const agents = await listAgents(rootPath ?? undefined); + const agents = await listAgents(cwd); const dependent = agents.filter((a: { mcpServers?: string[] }) => a.mcpServers?.includes(editingName) ); @@ -152,7 +156,7 @@ export default function McpPanel() { return; } } - await updateMcpServer(rootPath ?? undefined, editingName, server); + await updateMcpServer(cwd, editingName, server); } cancelForm(); await load(); @@ -164,7 +168,7 @@ export default function McpPanel() { const confirmDelete = async () => { if (!deletingName) return; try { - const agents = await listAgents(rootPath ?? undefined); + const agents = await listAgents(cwd); const dependent = agents.filter((a: { mcpServers?: string[] }) => a.mcpServers?.includes(deletingName) ); @@ -177,7 +181,7 @@ export default function McpPanel() { ) return; } - await deleteMcpServer(rootPath ?? undefined, deletingName); + await deleteMcpServer(cwd, deletingName); setDeletingName(null); await load(); } catch (e: any) { @@ -276,6 +280,21 @@ export default function McpPanel() { > {s.transport} + {s.source === 'global' && ( + + 全局 + + )} + {s.source === 'project' && ( + + 项目 + + )} + {s.hasProjectOverride && ( + + 覆盖全局 + + )}
{s.toolCount} 个工具
diff --git a/packages/desktop/src/settings/ProjectSettingsPage.tsx b/packages/desktop/src/settings/ProjectSettingsPage.tsx new file mode 100644 index 0000000..5cb3bd1 --- /dev/null +++ b/packages/desktop/src/settings/ProjectSettingsPage.tsx @@ -0,0 +1,68 @@ +import { ArrowLeft } from 'lucide-react'; +import { useGlobalStore } from '../stores/global.store'; +import { useState } from 'react'; +import McpPanel from './McpPanel'; +import HooksPanel from './HooksPanel'; +import SubagentsPanel from './SubagentsPanel'; +import SkillPanel from './SkillPanel'; +import MemoryPanel from './MemoryPanel'; + +type Section = 'mcp' | 'hooks' | 'agents' | 'skills' | 'memory'; + +const NAV_ITEMS: { id: Section; label: string }[] = [ + { id: 'mcp', label: 'MCP 服务器' }, + { id: 'hooks', label: '钩子' }, + { id: 'agents', label: '子智能体' }, + { id: 'skills', label: 'Skills' }, + { id: 'memory', label: '记忆模式' }, +]; + +export default function ProjectSettingsPage() { + const setView = useGlobalStore((s) => s.setView); + const [section, setSection] = useState
('mcp'); + + return ( +
+ {/* Header */} +
+ + 项目设置 +
+ +
+ {/* Left sidebar */} + + + {/* Content area */} +
+ {section === 'mcp' && } + {section === 'hooks' && } + {section === 'agents' && } + {section === 'skills' && } + {section === 'memory' && } +
+
+
+ ); +} diff --git a/packages/desktop/src/settings/SettingsPage.tsx b/packages/desktop/src/settings/SettingsPage.tsx deleted file mode 100644 index 5f08f30..0000000 --- a/packages/desktop/src/settings/SettingsPage.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { useState } from 'react'; -import { ArrowLeft, Server, Star, Link2, Users, Brain, Moon, Sun, BookOpen } from 'lucide-react'; -import { useGlobalStore } from '../stores/global.store'; -import McpPanel from './McpPanel'; -import SkillPanel from './SkillPanel'; -import HooksPanel from './HooksPanel'; -import SubagentsPanel from './SubagentsPanel'; -import MemoryPanel from './MemoryPanel'; - -type Section = 'mcp' | 'skills' | 'hooks' | 'agents' | 'memory'; - -const NAV_ITEMS: { id: Section; label: string; icon: React.ReactNode }[] = [ - { - id: 'mcp', - label: 'MCP 服务器', - icon: , - }, - { - id: 'skills', - label: 'Skills', - icon: , - }, - { - id: 'hooks', - label: '钩子', - icon: , - }, - { - id: 'agents', - label: '子智能体', - icon: , - }, - { - id: 'memory', - label: '记忆模式', - icon: , - }, -]; - -const THEMES = [ - { id: 'dark' as const, label: '深色', icon: }, - { id: 'light' as const, label: '浅色', icon: }, - { id: 'paper' as const, label: '纸黄', icon: }, -]; - -export default function SettingsPage() { - const setView = useGlobalStore((s) => s.setView); - const [section, setSection] = useState
('mcp'); - const theme = useGlobalStore((s) => s.ui.theme); - const setTheme = useGlobalStore((s) => s.setTheme); - - return ( -
- {/* Header */} -
- - 设置 -
- - {/* Body: sidebar + content */} -
- {/* Left sidebar */} - - - {/* Content area */} -
- {section === 'mcp' && } - {section === 'skills' && } - {section === 'hooks' && } - {section === 'agents' && } - {section === 'memory' && } -
-
-
- ); -} diff --git a/packages/desktop/src/settings/SkillPanel.tsx b/packages/desktop/src/settings/SkillPanel.tsx index 58a8422..c664e49 100644 --- a/packages/desktop/src/settings/SkillPanel.tsx +++ b/packages/desktop/src/settings/SkillPanel.tsx @@ -1,21 +1,26 @@ import { useState, useEffect } from 'react'; import Toggle from './Toggle'; +import { useGlobalStore } from '../stores/global.store'; import { listSkills, toggleSkill } from '../lib/core-api'; interface SkillEntry { name: string; description: string; disabled: boolean; + source?: 'global' | 'project'; + hasProjectOverride?: boolean; } -export default function SkillPanel() { +export default function SkillPanel({ global: isGlobal }: { global?: boolean }) { const [skills, setSkills] = useState([]); const [loading, setLoading] = useState(true); + const rootPath = useGlobalStore((s) => s.workspace.rootPath); + const cwd = isGlobal ? undefined : rootPath; const load = async () => { setLoading(true); try { - const data = await listSkills(); + const data = await listSkills(cwd); setSkills(data ?? []); } catch { setSkills([]); @@ -26,10 +31,10 @@ export default function SkillPanel() { useEffect(() => { load(); - }, []); + }, [rootPath, isGlobal]); const toggle = async (name: string, disabled: boolean) => { - await toggleSkill(name, !disabled); + await toggleSkill(name, !disabled, cwd); setSkills((prev) => prev.map((s) => (s.name === name ? { ...s, disabled } : s))); }; @@ -55,7 +60,24 @@ export default function SkillPanel() { className="flex items-center gap-4 px-4 py-3.5 rounded-xl bg-[var(--bg-card)] border border-[var(--border-card)]" >
- {s.name} +
+ {s.name} + {s.source === 'global' && ( + + 全局 + + )} + {s.source === 'project' && ( + + 项目 + + )} + {s.hasProjectOverride && ( + + 覆盖全局 + + )} +
{s.description && (
{s.description}
)} diff --git a/packages/desktop/src/settings/SubagentsPanel.tsx b/packages/desktop/src/settings/SubagentsPanel.tsx index 53d1cc2..586a0a1 100644 --- a/packages/desktop/src/settings/SubagentsPanel.tsx +++ b/packages/desktop/src/settings/SubagentsPanel.tsx @@ -6,6 +6,8 @@ import { getSubagentEnabled, setSubagentEnabled, setAgentDisabled, + resetSubagentEnabled, + resetAgentDisabled, createAgent, updateAgent, deleteAgent, @@ -37,6 +39,8 @@ interface AgentEntry { maxSteps?: number; model?: string; disabled?: boolean; + source?: 'builtin' | 'global' | 'project'; + hasProjectOverride?: boolean; } interface AgentForm { @@ -63,9 +67,10 @@ const EMPTY_FORM: AgentForm = { const BUILT_IN = new Set(['explore', 'general']); -export default function SubagentsPanel() { +export default function SubagentsPanel({ global: isGlobal }: { global?: boolean }) { const [agents, setAgents] = useState([]); const [enabled, setEnabled] = useState(true); + const [enabledSource, setEnabledSource] = useState<'global' | 'project'>('global'); const [loading, setLoading] = useState(true); const [models, setModels] = useState([]); const [mcpList, setMcpList] = useState([]); @@ -74,18 +79,20 @@ export default function SubagentsPanel() { const [deletingName, setDeletingName] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const rootPath = useGlobalStore((s) => s.workspace.rootPath); + const cwd = isGlobal ? undefined : rootPath; const load = async () => { setLoading(true); try { const [agentsData, enabledData, modelData, mcpData] = await Promise.all([ - listAgents(rootPath ?? undefined), - getSubagentEnabled(), + listAgents(cwd), + getSubagentEnabled(cwd), import('../lib/core-api').then((m) => m.listModels()), listMcpServers(), ]); setAgents(agentsData ?? []); setEnabled(enabledData.enabled ?? true); + setEnabledSource((enabledData.source as 'global' | 'project') ?? 'global'); setModels((modelData.models ?? []) as ModelEntry[]); setMcpList((mcpData ?? []).map((s: { name: string }) => s.name)); } catch { @@ -100,12 +107,19 @@ export default function SubagentsPanel() { }, [rootPath]); const toggleEnabled = async (v: boolean) => { - await setSubagentEnabled(v); + await setSubagentEnabled(v, cwd); setEnabled(v); + setEnabledSource(isGlobal ? 'global' : 'project'); + }; + + const resetEnabled = async () => { + if (!cwd) return; + await resetSubagentEnabled(cwd); + await load(); }; const toggleAgent = async (name: string, disabled: boolean) => { - await setAgentDisabled(name, disabled); + await setAgentDisabled(name, disabled, cwd); setAgents((prev) => prev.map((a) => (a.name === name ? { ...a, disabled } : a))); }; @@ -151,9 +165,9 @@ export default function SubagentsPanel() { try { if (isCreating) { - await createAgent(rootPath ?? undefined, profile); + await createAgent(cwd, profile); } else if (editingName) { - await updateAgent(rootPath ?? undefined, editingName, profile); + await updateAgent(cwd, editingName, profile); } cancelForm(); await load(); @@ -165,7 +179,7 @@ export default function SubagentsPanel() { const confirmDelete = async () => { if (!deletingName) return; try { - await deleteAgent(rootPath ?? undefined, deletingName); + await deleteAgent(cwd, deletingName); setDeletingName(null); await load(); } catch (e: any) { @@ -189,9 +203,21 @@ export default function SubagentsPanel() {
启用子智能体
-
允许 agent 派发子任务给子智能体
+
+ 允许 agent 派发子任务给子智能体{!isGlobal && enabledSource === 'project' ? '(项目级覆盖)' : ''} +
+
+
+ {!isGlobal && enabledSource === 'project' && ( + + )} +
-
@@ -282,11 +308,26 @@ export default function SubagentsPanel() { {a.model} )} - {isBuiltIn && ( + {a.source === 'builtin' && ( 内置 )} + {a.source === 'global' && ( + + 全局 + + )} + {a.source === 'project' && ( + + 项目 + + )} + {a.hasProjectOverride && ( + + 覆盖全局 + + )}
{a.description}
{a.tools && a.tools.length > 0 && ( diff --git a/packages/desktop/src/stores/global.store.ts b/packages/desktop/src/stores/global.store.ts index b3f9597..fe744d7 100644 --- a/packages/desktop/src/stores/global.store.ts +++ b/packages/desktop/src/stores/global.store.ts @@ -30,7 +30,7 @@ export interface ModelEntry { interface UIState { mode: 'agent' | 'ide'; - view: 'agent' | 'settings'; + view: 'agent' | 'global-settings' | 'project-settings'; sidebarCollapsed: boolean; sidebarWidth: number; rightPanelWidth: number; @@ -720,6 +720,7 @@ export const useGlobalStore = create()( rightPanelWidth: state.ui.rightPanelWidth, bottomPanelHeight: state.ui.bottomPanelHeight, ideSidebarView: state.ui.ideSidebarView, + theme: state.ui.theme, }, workspace: { rootPath: state.workspace.rootPath, diff --git a/packages/desktop/test/core-api.test.ts b/packages/desktop/test/core-api.test.ts new file mode 100644 index 0000000..87f5524 --- /dev/null +++ b/packages/desktop/test/core-api.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockSettings } = vi.hoisted(() => { + const mockSettings = { + getSubagentEnabled: vi.fn(), + setSubagentEnabled: vi.fn(), + resetSubagentEnabled: vi.fn(), + setAgentDisabled: vi.fn(), + resetAgentDisabled: vi.fn(), + setMcpDisabled: vi.fn(), + resetMcpDisabled: vi.fn(), + setHookDisabled: vi.fn(), + resetHookDisabled: vi.fn(), + toggleSkill: vi.fn(), + }; + return { mockSettings }; +}); + +vi.mock('../src/lib/api', () => ({ + API_BASE: 'http://localhost:3000', +})); + +vi.mock('@codingcode/core/client/http', () => ({ + createHttpClients: () => ({ + settings: mockSettings, + models: { listModels: vi.fn(), switchModel: vi.fn() }, + sessions: { listSessions: vi.fn() }, + agent: { sendApprovalResponse: vi.fn() }, + }), +})); + +import { + getSubagentEnabled, + setSubagentEnabled, + resetSubagentEnabled, + setAgentDisabled, + resetAgentDisabled, + setMcpDisabled, + resetMcpDisabled, + setHookDisabled, + resetHookDisabled, + toggleSkill, +} from '../src/lib/core-api'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ---- getSubagentEnabled ---- + +describe('getSubagentEnabled', () => { + it('calls settings.getSubagentEnabled with empty cwd when no cwd provided', async () => { + mockSettings.getSubagentEnabled.mockResolvedValue({ enabled: true, source: 'global' }); + const result = await getSubagentEnabled(); + expect(mockSettings.getSubagentEnabled).toHaveBeenCalledWith({ cwd: '' }); + expect(result).toEqual({ enabled: true, source: 'global' }); + }); + + it('calls settings.getSubagentEnabled with provided cwd', async () => { + mockSettings.getSubagentEnabled.mockResolvedValue({ enabled: false, source: 'project' }); + const result = await getSubagentEnabled('/some/path'); + expect(mockSettings.getSubagentEnabled).toHaveBeenCalledWith({ cwd: '/some/path' }); + expect(result).toEqual({ enabled: false, source: 'project' }); + }); +}); + +// ---- setSubagentEnabled ---- + +describe('setSubagentEnabled', () => { + it('calls settings.setSubagentEnabled with empty cwd when no cwd provided', async () => { + mockSettings.setSubagentEnabled.mockResolvedValue(undefined); + await setSubagentEnabled(true); + expect(mockSettings.setSubagentEnabled).toHaveBeenCalledWith({ enabled: true, cwd: '' }); + }); + + it('calls settings.setSubagentEnabled with provided cwd', async () => { + mockSettings.setSubagentEnabled.mockResolvedValue(undefined); + await setSubagentEnabled(false, '/project/dir'); + expect(mockSettings.setSubagentEnabled).toHaveBeenCalledWith({ enabled: false, cwd: '/project/dir' }); + }); +}); + +// ---- resetSubagentEnabled ---- + +describe('resetSubagentEnabled', () => { + it('calls settings.resetSubagentEnabled with cwd', async () => { + mockSettings.resetSubagentEnabled.mockResolvedValue(undefined); + await resetSubagentEnabled('/project/dir'); + expect(mockSettings.resetSubagentEnabled).toHaveBeenCalledWith({ cwd: '/project/dir' }); + }); +}); + +// ---- setAgentDisabled ---- + +describe('setAgentDisabled', () => { + it('calls settings.setAgentDisabled with empty cwd when no cwd provided', async () => { + mockSettings.setAgentDisabled.mockResolvedValue(undefined); + await setAgentDisabled('explore', true); + expect(mockSettings.setAgentDisabled).toHaveBeenCalledWith({ name: 'explore', disabled: true, cwd: '' }); + }); + + it('calls settings.setAgentDisabled with provided cwd', async () => { + mockSettings.setAgentDisabled.mockResolvedValue(undefined); + await setAgentDisabled('explore', false, '/project/dir'); + expect(mockSettings.setAgentDisabled).toHaveBeenCalledWith({ name: 'explore', disabled: false, cwd: '/project/dir' }); + }); +}); + +// ---- resetAgentDisabled ---- + +describe('resetAgentDisabled', () => { + it('calls settings.resetAgentDisabled with name and cwd', async () => { + mockSettings.resetAgentDisabled.mockResolvedValue(undefined); + await resetAgentDisabled('explore', '/project/dir'); + expect(mockSettings.resetAgentDisabled).toHaveBeenCalledWith({ name: 'explore', cwd: '/project/dir' }); + }); +}); + +// ---- setMcpDisabled ---- + +describe('setMcpDisabled', () => { + it('calls settings.setMcpDisabled with empty cwd when no cwd provided', async () => { + mockSettings.setMcpDisabled.mockResolvedValue(undefined); + await setMcpDisabled('mcp-server', true); + expect(mockSettings.setMcpDisabled).toHaveBeenCalledWith({ name: 'mcp-server', disabled: true, cwd: '' }); + }); + + it('calls settings.setMcpDisabled with provided cwd', async () => { + mockSettings.setMcpDisabled.mockResolvedValue(undefined); + await setMcpDisabled('mcp-server', false, '/project/dir'); + expect(mockSettings.setMcpDisabled).toHaveBeenCalledWith({ name: 'mcp-server', disabled: false, cwd: '/project/dir' }); + }); +}); + +// ---- resetMcpDisabled ---- + +describe('resetMcpDisabled', () => { + it('calls settings.resetMcpDisabled with name and cwd', async () => { + mockSettings.resetMcpDisabled.mockResolvedValue(undefined); + await resetMcpDisabled('mcp-server', '/project/dir'); + expect(mockSettings.resetMcpDisabled).toHaveBeenCalledWith({ name: 'mcp-server', cwd: '/project/dir' }); + }); +}); + +// ---- setHookDisabled ---- + +describe('setHookDisabled', () => { + it('calls settings.setHookDisabled with provided cwd', async () => { + mockSettings.setHookDisabled.mockResolvedValue(undefined); + await setHookDisabled('/project/dir', 'my-hook', true); + expect(mockSettings.setHookDisabled).toHaveBeenCalledWith({ cwd: '/project/dir', name: 'my-hook', disabled: true }); + }); + + it('calls settings.setHookDisabled with empty string when cwd is undefined', async () => { + mockSettings.setHookDisabled.mockResolvedValue(undefined); + await setHookDisabled(undefined, 'my-hook', false); + expect(mockSettings.setHookDisabled).toHaveBeenCalledWith({ cwd: '', name: 'my-hook', disabled: false }); + }); +}); + +// ---- resetHookDisabled ---- + +describe('resetHookDisabled', () => { + it('calls settings.resetHookDisabled with name and cwd', async () => { + mockSettings.resetHookDisabled.mockResolvedValue(undefined); + await resetHookDisabled('my-hook', '/project/dir'); + expect(mockSettings.resetHookDisabled).toHaveBeenCalledWith({ name: 'my-hook', cwd: '/project/dir' }); + }); +}); + +// ---- toggleSkill ---- + +describe('toggleSkill', () => { + it('calls settings.toggleSkill with empty cwd when no cwd provided', async () => { + mockSettings.toggleSkill.mockResolvedValue(undefined); + await toggleSkill('my-skill', true); + expect(mockSettings.toggleSkill).toHaveBeenCalledWith('my-skill', true, { cwd: '' }); + }); + + it('calls settings.toggleSkill with provided cwd', async () => { + mockSettings.toggleSkill.mockResolvedValue(undefined); + await toggleSkill('my-skill', false, '/project/dir'); + expect(mockSettings.toggleSkill).toHaveBeenCalledWith('my-skill', false, { cwd: '/project/dir' }); + }); +}); diff --git a/packages/desktop/test/settings-panels.test.ts b/packages/desktop/test/settings-panels.test.ts index bf2fc8e..dc630dc 100644 --- a/packages/desktop/test/settings-panels.test.ts +++ b/packages/desktop/test/settings-panels.test.ts @@ -9,6 +9,9 @@ interface AgentEntry { readonly?: boolean; maxSteps?: number; model?: string; + disabled?: boolean; + source?: 'builtin' | 'global' | 'project'; + hasProjectOverride?: boolean; } function mapAgentToEntry(profile: AgentEntry): AgentEntry { @@ -286,3 +289,230 @@ describe('SubagentsPanel - tool multi-select form logic', () => { expect(result).toEqual(['execute_command']); }); }); + +// --- SubagentsPanel - source and enabledSource logic --- + +describe('SubagentsPanel - source and enabledSource', () => { + it('defaults enabledSource to global when API returns no source', () => { + const apiSource = undefined; + const enabledSource = (apiSource as 'global' | 'project' | undefined) ?? 'global'; + expect(enabledSource).toBe('global'); + }); + + it('sets enabledSource to project when API returns project', () => { + const apiSource = 'project'; + const enabledSource = (apiSource as 'global' | 'project') ?? 'global'; + expect(enabledSource).toBe('project'); + }); + + it('sets enabledSource to global when API returns global', () => { + const apiSource = 'global'; + const enabledSource = (apiSource as 'global' | 'project') ?? 'global'; + expect(enabledSource).toBe('global'); + }); + + it('agent source=builtin renders 内置 tag', () => { + const agent: AgentEntry = { name: 'explore', description: 'Explore', source: 'builtin' }; + expect(agent.source).toBe('builtin'); + }); + + it('agent source=global renders 全局 tag', () => { + const agent: AgentEntry = { name: 'my-agent', description: 'My agent', source: 'global' }; + expect(agent.source).toBe('global'); + }); + + it('agent source=project renders 项目 tag', () => { + const agent: AgentEntry = { name: 'proj-agent', description: 'Project agent', source: 'project' }; + expect(agent.source).toBe('project'); + }); + + it('agent hasProjectOverride=true renders 覆盖全局 tag', () => { + const agent: AgentEntry = { name: 'override-agent', description: 'Override', source: 'global', hasProjectOverride: true }; + expect(agent.hasProjectOverride).toBe(true); + }); + + it('agent without source has undefined source', () => { + const agent: AgentEntry = { name: 'basic', description: 'Basic' }; + expect(agent.source).toBeUndefined(); + }); + + it('resetEnabled returns early when cwd is undefined', () => { + const cwd = undefined; + const shouldReset = !!cwd; + expect(shouldReset).toBe(false); + }); + + it('resetEnabled proceeds when cwd is defined', () => { + const cwd = '/some/project'; + const shouldReset = !!cwd; + expect(shouldReset).toBe(true); + }); + + it('toggleEnabled sets enabledSource to project in project context', () => { + const isGlobal = false; + const enabledSource = isGlobal ? 'global' : 'project'; + expect(enabledSource).toBe('project'); + }); + + it('toggleEnabled sets enabledSource to global in global context', () => { + const isGlobal = true; + const enabledSource = isGlobal ? 'global' : 'project'; + expect(enabledSource).toBe('global'); + }); + + it('shows 项目级覆盖 marker when not global and enabledSource is project', () => { + const isGlobal = false; + const enabledSource = 'project'; + const showOverride = !isGlobal && enabledSource === 'project'; + expect(showOverride).toBe(true); + }); + + it('hides 项目级覆盖 marker when is global', () => { + const isGlobal = true; + const enabledSource = 'project'; + const showOverride = !isGlobal && enabledSource === 'project'; + expect(showOverride).toBe(false); + }); + + it('hides 项目级覆盖 marker when enabledSource is global', () => { + const isGlobal = false; + const enabledSource = 'global'; + const showOverride = !isGlobal && enabledSource === 'project'; + expect(showOverride).toBe(false); + }); +}); + +// --- McpPanel source tag and cwd logic --- + +interface McpEntry { + name: string; + transport: 'stdio' | 'http'; + disabled: boolean; + toolCount: number; + source?: 'global' | 'project'; + hasProjectOverride?: boolean; +} + +describe('McpPanel - source tag and cwd', () => { + it('McpEntry accepts source=global', () => { + const entry: McpEntry = { name: 'm1', transport: 'stdio', disabled: false, toolCount: 3, source: 'global' }; + expect(entry.source).toBe('global'); + expect(entry.hasProjectOverride).toBeUndefined(); + }); + + it('McpEntry accepts source=project', () => { + const entry: McpEntry = { name: 'm2', transport: 'http', disabled: false, toolCount: 1, source: 'project' }; + expect(entry.source).toBe('project'); + }); + + it('McpEntry accepts hasProjectOverride=true', () => { + const entry: McpEntry = { name: 'm3', transport: 'stdio', disabled: false, toolCount: 0, source: 'global', hasProjectOverride: true }; + expect(entry.hasProjectOverride).toBe(true); + }); + + it('cwd is undefined when isGlobal=true', () => { + const isGlobal = true; + const rootPath = '/some/project'; + const cwd = isGlobal ? undefined : rootPath; + expect(cwd).toBeUndefined(); + }); + + it('cwd equals rootPath when isGlobal=false', () => { + const isGlobal = false; + const rootPath = '/some/project'; + const cwd = isGlobal ? undefined : rootPath; + expect(cwd).toBe('/some/project'); + }); + + it('toggle passes cwd to setMcpDisabled', () => { + const calls: Array<{ name: string; disabled: boolean; cwd?: string }> = []; + const setMcpDisabled = (name: string, disabled: boolean, cwd?: string) => { + calls.push({ name, disabled, cwd }); + }; + const cwd = '/project/path'; + setMcpDisabled('server1', true, cwd); + expect(calls[0]).toEqual({ name: 'server1', disabled: true, cwd: '/project/path' }); + }); +}); + +// --- HooksPanel source tag and cwd logic --- + +interface HookEntry { + name: string; + description?: string; + point: string; + type: 'observer' | 'decision'; + command: string; + args?: string[]; + env?: Record; + priority?: number; + enabled: boolean; + source?: 'global' | 'project'; + hasProjectOverride?: boolean; +} + +describe('HooksPanel - source tag and cwd', () => { + it('HookEntry accepts source=global', () => { + const entry: HookEntry = { name: 'h1', point: 'tool.execute.before', type: 'decision', command: 'echo', enabled: true, source: 'global' }; + expect(entry.source).toBe('global'); + expect(entry.hasProjectOverride).toBeUndefined(); + }); + + it('HookEntry accepts source=project', () => { + const entry: HookEntry = { name: 'h2', point: 'tool.execute.after', type: 'observer', command: 'echo', enabled: true, source: 'project' }; + expect(entry.source).toBe('project'); + }); + + it('HookEntry accepts hasProjectOverride=true', () => { + const entry: HookEntry = { name: 'h3', point: 'llm.request.before', type: 'decision', command: 'echo', enabled: true, source: 'global', hasProjectOverride: true }; + expect(entry.hasProjectOverride).toBe(true); + }); + + it('setHookDisabled is called with cwd, name, disabled', () => { + const calls: Array<{ cwd: string | undefined; name: string; disabled: boolean }> = []; + const setHookDisabled = (cwd: string | undefined, name: string, disabled: boolean) => { + calls.push({ cwd, name, disabled }); + }; + const cwd = '/project/path'; + setHookDisabled(cwd, 'hook1', true); + expect(calls[0]).toEqual({ cwd: '/project/path', name: 'hook1', disabled: true }); + }); +}); + +// --- SkillPanel source tag and cwd logic --- + +interface SkillEntry { + name: string; + description: string; + disabled: boolean; + source?: 'global' | 'project'; + hasProjectOverride?: boolean; +} + +describe('SkillPanel - source tag and cwd', () => { + it('SkillEntry accepts source=global', () => { + const entry: SkillEntry = { name: 's1', description: 'A skill', disabled: false, source: 'global' }; + expect(entry.source).toBe('global'); + expect(entry.hasProjectOverride).toBeUndefined(); + }); + + it('SkillEntry accepts source=project', () => { + const entry: SkillEntry = { name: 's2', description: 'Another skill', disabled: false, source: 'project' }; + expect(entry.source).toBe('project'); + }); + + it('SkillEntry accepts hasProjectOverride=true', () => { + const entry: SkillEntry = { name: 's3', description: 'Override skill', disabled: false, source: 'global', hasProjectOverride: true }; + expect(entry.hasProjectOverride).toBe(true); + }); + + it('toggleSkill is called with name, enabled, cwd', () => { + const calls: Array<{ name: string; enabled: boolean; cwd?: string }> = []; + const toggleSkill = (name: string, enabled: boolean, cwd?: string) => { + calls.push({ name, enabled, cwd }); + }; + const cwd = '/project/path'; + toggleSkill('skill1', true, cwd); + expect(calls[0]).toEqual({ name: 'skill1', enabled: true, cwd: '/project/path' }); + }); +}); diff --git a/packages/tui/src/components/App.tsx b/packages/tui/src/components/App.tsx index d60ea14..acacf9e 100644 --- a/packages/tui/src/components/App.tsx +++ b/packages/tui/src/components/App.tsx @@ -183,7 +183,7 @@ export function App({ client }: AppProps) { try { const arg = parsed.args.trim().toLowerCase(); if (arg === 'on' || arg === 'off') { - await client.setSubagentEnabled(arg === 'on'); + await client.setSubagentEnabled({ enabled: arg === 'on', cwd: '' }); setStaticMessages((prev) => [ ...prev, { @@ -194,7 +194,8 @@ export function App({ client }: AppProps) { }, ]); } else { - const enabled = await client.getSubagentEnabled(); + const result = await client.getSubagentEnabled({ cwd: '' }); + const enabled = result.enabled; setStaticMessages((prev) => [ ...prev, { @@ -535,9 +536,9 @@ export function App({ client }: AppProps) { if (!server) return; try { if (server.disabled) { - await client.enableMcp(value); + await client.setMcpDisabled({ name: value, disabled: false, cwd: '' }); } else { - await client.disableMcp(value); + await client.setMcpDisabled({ name: value, disabled: true, cwd: '' }); } const updated = await client.getMcpStatus(); setPanel({ type: 'mcp', servers: updated }); @@ -565,7 +566,7 @@ export function App({ client }: AppProps) { const skill = panel.skills.find((s) => s.name === value); if (!skill) return; try { - await client.toggleSkill(value, !skill.enabled); + await client.toggleSkill({ name: value, enabled: !skill.enabled, cwd: '' }); const updated = await client.listSkills(); setPanel({ type: 'skill', skills: updated }); } catch { From bff62215f64a93d65b4c10067a62dbb2ae523180 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 9 Jun 2026 01:52:27 +0800 Subject: [PATCH 2/2] fix test fail --- packages/codingcode/src/agent/agent.ts | 3 +- packages/codingcode/src/client/direct.ts | 6 +- .../codingcode/src/client/direct/settings.ts | 22 ++- .../codingcode/src/client/http/settings.ts | 27 +++- packages/codingcode/src/hooks/config.ts | 32 +++-- packages/codingcode/src/mcp/config.ts | 32 +++-- .../codingcode/src/runtime/project-runtime.ts | 5 +- .../codingcode/src/server/routes/settings.ts | 126 ++++++++++-------- packages/codingcode/src/skills/config.ts | 16 ++- packages/codingcode/src/skills/index.ts | 4 +- packages/codingcode/src/subagent/registry.ts | 21 ++- .../src/tools/domains/subagent/dispatch.ts | 30 ++--- .../test/agent/agent-concurrent.test.ts | 1 + .../test/agent/agent-todo-event.test.ts | 1 + packages/codingcode/test/agent/agent.test.ts | 1 + .../test/agent/hooks-deps-type.test.ts | 1 + .../test/agent/loop-options.test.ts | 1 + .../test/agent/memory-snapshot.test.ts | 12 +- .../codingcode/test/agent/stop-hook.test.ts | 1 + .../test/ci/tooling-scripts.test.ts | 1 - .../test/client/direct/settings.test.ts | 27 +++- .../test/client/http/settings.test.ts | 28 ++-- .../codingcode/test/context/context.test.ts | 3 +- .../test/hooks/config-merge.test.ts | 57 ++++++-- .../codingcode/test/mcp/config-merge.test.ts | 83 +++++++++--- packages/codingcode/test/orchestrate.test.ts | 5 +- .../codingcode/test/server/handler.test.ts | 5 +- .../test/server/settings-routes.test.ts | 91 +++++++++---- .../test/session/view-assembly.test.ts | 4 +- .../codingcode/test/subagent/switch.test.ts | 37 ++++- packages/desktop/src/agent/AgentSidebar.tsx | 10 +- packages/desktop/src/agent/MessageStream.tsx | 42 ++++-- packages/desktop/src/agent/ProjectStrip.tsx | 16 ++- packages/desktop/src/agent/TodoPanel.tsx | 12 +- packages/desktop/src/lib/core-api.ts | 16 ++- packages/desktop/src/settings/HooksPanel.tsx | 38 ++++-- packages/desktop/src/settings/McpPanel.tsx | 21 ++- packages/desktop/src/settings/MemoryPanel.tsx | 29 ++-- packages/desktop/src/settings/SkillPanel.tsx | 4 +- .../desktop/src/settings/SubagentsPanel.tsx | 28 ++-- packages/desktop/src/shared/ErrorBoundary.tsx | 4 +- .../desktop/src/shared/MarkdownRenderer.tsx | 18 ++- packages/desktop/src/shared/MessageItem.tsx | 10 +- packages/desktop/src/shared/ToolCallCard.tsx | 8 +- packages/desktop/src/shared/ToolSummary.tsx | 14 +- .../desktop/src/shared/UnifiedDiffView.tsx | 12 +- packages/desktop/src/stores/global.store.ts | 2 - .../desktop/test/MarkdownRenderer.test.ts | 12 +- packages/desktop/test/core-api.test.ts | 60 +++++++-- packages/desktop/test/selection.test.ts | 18 --- packages/desktop/test/settings-panels.test.ts | 88 ++++++++++-- .../test/use-copy-to-clipboard.test.tsx | 10 +- packages/test-fixture-global/config.yaml | 3 + packages/tui/src/components/CodeBlock.tsx | 36 ++--- packages/tui/src/components/MarkdownText.tsx | 92 +++++++------ packages/tui/src/utils.ts | 2 - .../tui/test/components/MarkdownText.test.ts | 2 +- 57 files changed, 903 insertions(+), 387 deletions(-) create mode 100644 packages/test-fixture-global/config.yaml diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index b1002b7..00d4968 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -319,7 +319,8 @@ export async function* runReActLoop( // Build tools from static builtin + MCP + dispatch let allToolDefs: ToolDefinition[] = [...STATIC_BUILTIN_TOOLS, ...(opts.mcpTools ?? [])]; - if (opts.dispatchTool && resolveSubagentEnabled(projectPath)) allToolDefs = [...allToolDefs, opts.dispatchTool]; + if (opts.dispatchTool && resolveSubagentEnabled(projectPath)) + allToolDefs = [...allToolDefs, opts.dispatchTool]; // Apply policy filter (derived from AgentProfile.tools via getToolPolicy) const allowedByPolicy = opts.toolPolicy?.allowedTools; diff --git a/packages/codingcode/src/client/direct.ts b/packages/codingcode/src/client/direct.ts index 6278314..2b93799 100644 --- a/packages/codingcode/src/client/direct.ts +++ b/packages/codingcode/src/client/direct.ts @@ -468,7 +468,11 @@ export async function createDirectClient(llm: any): Promise { }, async setHookDisabled(body: { name: string; disabled: boolean; cwd: string }): Promise { - await clients.settings.setHookDisabled({ cwd: cwd(), name: body.name, disabled: body.disabled }); + await clients.settings.setHookDisabled({ + cwd: cwd(), + name: body.name, + disabled: body.disabled, + }); }, async resetHookDisabled(body: { name: string; cwd: string }): Promise { diff --git a/packages/codingcode/src/client/direct/settings.ts b/packages/codingcode/src/client/direct/settings.ts index be89a05..cafde81 100644 --- a/packages/codingcode/src/client/direct/settings.ts +++ b/packages/codingcode/src/client/direct/settings.ts @@ -6,7 +6,14 @@ import { getGlobalPermissionMode, setGlobalPermissionMode } from '../../approval import type { PermissionMode } from '../../approval/types.js'; import type { AgentProfile } from '../../subagent/registry.js'; import type { UserHookConfig } from '../../hooks/config.js'; -import { loadMcpConfig, writeMcpConfig, resolveMcpDisabled, setGlobalMcpDisabledState, setProjectMcpDisabledState, resetProjectMcpDisabledState } from '../../mcp/config.js'; +import { + loadMcpConfig, + writeMcpConfig, + resolveMcpDisabled, + setGlobalMcpDisabledState, + setProjectMcpDisabledState, + resetProjectMcpDisabledState, +} from '../../mcp/config.js'; import { loadAgentProfiles, writeAgentProfile, @@ -26,7 +33,14 @@ import { resolveAgentDisabled, getProjectAgentDisabledState, } from '../../subagent/registry.js'; -import { loadHookConfigs, writeHookConfigs, resolveHookDisabled, setGlobalHookDisabledState, setProjectHookDisabledState, resetProjectHookDisabledState } from '../../hooks/config.js'; +import { + loadHookConfigs, + writeHookConfigs, + resolveHookDisabled, + setGlobalHookDisabledState, + setProjectHookDisabledState, + resetProjectHookDisabledState, +} from '../../hooks/config.js'; import { setHookRuntimeEnabled } from '../../hooks/executor.js'; import { getMemoryConfig, @@ -300,7 +314,9 @@ export function createDirectSettingsClient( Effect.gen(function* () { const skill = yield* SkillService; const skillCwd = cwd || process.cwd(); - return yield* enabled ? skill.enableSkill(skillCwd, name) : skill.disableSkill(skillCwd, name); + return yield* enabled + ? skill.enableSkill(skillCwd, name) + : skill.disableSkill(skillCwd, name); }) ); }, diff --git a/packages/codingcode/src/client/http/settings.ts b/packages/codingcode/src/client/http/settings.ts index ce1d1f7..64f080b 100644 --- a/packages/codingcode/src/client/http/settings.ts +++ b/packages/codingcode/src/client/http/settings.ts @@ -82,7 +82,9 @@ export function createHttpSettingsClient( }, async getSubagentEnabled({ cwd }) { - return apiGet<{ enabled: boolean; source: string }>(`/api/settings/subagent/enabled${qsCwd(cwd)}`); + return apiGet<{ enabled: boolean; source: string }>( + `/api/settings/subagent/enabled${qsCwd(cwd)}` + ); }, async setSubagentEnabled({ enabled, cwd }) { @@ -98,11 +100,16 @@ export function createHttpSettingsClient( }, async setMcpDisabled({ name, disabled, cwd }) { - await apiPost(`/api/settings/mcp/${encodeURIComponent(name)}/disabled${qsCwd(cwd)}`, { disabled }); + await apiPost(`/api/settings/mcp/${encodeURIComponent(name)}/disabled${qsCwd(cwd)}`, { + disabled, + }); }, async resetMcpDisabled({ name, cwd }) { - await apiPost(`/api/settings/mcp/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, {}); + await apiPost( + `/api/settings/mcp/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, + {} + ); }, async createMcpServer({ cwd, server }) { @@ -142,11 +149,16 @@ export function createHttpSettingsClient( }, async setAgentDisabled({ name, disabled, cwd }) { - await apiPost(`/api/settings/agents/${encodeURIComponent(name)}/disabled${qsCwd(cwd)}`, { disabled }); + await apiPost(`/api/settings/agents/${encodeURIComponent(name)}/disabled${qsCwd(cwd)}`, { + disabled, + }); }, async resetAgentDisabled({ name, cwd }) { - await apiPost(`/api/settings/agents/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, {}); + await apiPost( + `/api/settings/agents/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, + {} + ); }, async listHooks({ cwd }) { @@ -172,7 +184,10 @@ export function createHttpSettingsClient( }, async resetHookDisabled({ name, cwd }) { - await apiPost(`/api/settings/hooks/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, {}); + await apiPost( + `/api/settings/hooks/${encodeURIComponent(name)}/disabled/reset${qsCwd(cwd)}`, + {} + ); }, async getGlobalPermissionMode() { diff --git a/packages/codingcode/src/hooks/config.ts b/packages/codingcode/src/hooks/config.ts index 9c585e0..7cd14bd 100644 --- a/packages/codingcode/src/hooks/config.ts +++ b/packages/codingcode/src/hooks/config.ts @@ -16,8 +16,15 @@ export interface UserHookConfig { enabled: boolean; } -function getGlobalConfigDir(): string { - return join(homedir(), '.codingcode'); +let _globalConfigDirOverride: string | undefined; + +export function getGlobalConfigDir(): string { + return _globalConfigDirOverride ?? join(homedir(), '.codingcode'); +} + +/** @internal Test-only hook to override the global config directory */ +export function _setGlobalConfigDir(dir: string | undefined): void { + _globalConfigDirOverride = dir; } function mergeByName(global: T[], project: T[]): T[] { @@ -54,10 +61,7 @@ export function writeHookConfigs(projectRoot: string, hooks: UserHookConfig[]): } export function loadGlobalHookConfigs(): UserHookConfig[] { - const paths = [ - join(getGlobalConfigDir(), 'hooks.yaml'), - join(getGlobalConfigDir(), 'hooks.yml'), - ]; + const paths = [join(getGlobalConfigDir(), 'hooks.yaml'), join(getGlobalConfigDir(), 'hooks.yml')]; for (const p of paths) { if (existsSync(p)) { const raw = readFileSync(p, 'utf8'); @@ -117,7 +121,10 @@ export function setGlobalHookDisabledState(hookName: string, disabled: boolean): // ---- 项目级 Hook disabled 状态:持久化到 .codingcode/config.yaml ---- -export function getProjectHookDisabledState(projectRoot: string, hookName: string): boolean | undefined { +export function getProjectHookDisabledState( + projectRoot: string, + hookName: string +): boolean | undefined { const p = join(projectRoot, '.codingcode', 'config.yaml'); if (!existsSync(p)) return undefined; try { @@ -130,7 +137,11 @@ export function getProjectHookDisabledState(projectRoot: string, hookName: strin } } -export function setProjectHookDisabledState(projectRoot: string, hookName: string, disabled: boolean): void { +export function setProjectHookDisabledState( + projectRoot: string, + hookName: string, + disabled: boolean +): void { const dir = join(projectRoot, '.codingcode'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const p = join(dir, 'config.yaml'); @@ -148,7 +159,10 @@ export function setProjectHookDisabledState(projectRoot: string, hookName: strin export function resetProjectHookDisabledState(projectRoot: string, hookName: string): void { const p = join(projectRoot, '.codingcode', 'config.yaml'); if (!existsSync(p)) return; - const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record; + const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record< + string, + unknown + >; const hooks = (existing.hooks as Record) ?? {}; const disabledHooks = hooks.disabledHooks as Record; if (disabledHooks) { diff --git a/packages/codingcode/src/mcp/config.ts b/packages/codingcode/src/mcp/config.ts index f640abf..7ea4f5d 100644 --- a/packages/codingcode/src/mcp/config.ts +++ b/packages/codingcode/src/mcp/config.ts @@ -17,8 +17,15 @@ function resolveEnvVars(value: unknown): unknown { return value; } -function getGlobalConfigDir(): string { - return join(homedir(), '.codingcode'); +let _globalConfigDirOverride: string | undefined; + +export function getGlobalConfigDir(): string { + return _globalConfigDirOverride ?? join(homedir(), '.codingcode'); +} + +/** @internal Test-only hook to override the global config directory */ +export function _setGlobalConfigDir(dir: string | undefined): void { + _globalConfigDirOverride = dir; } function mergeByName(global: T[], project: T[]): T[] { @@ -55,10 +62,7 @@ export function writeMcpConfig(projectRoot: string, servers: McpServerConfig[]): } export function loadGlobalMcpConfig(): McpServerConfig[] { - const paths = [ - join(getGlobalConfigDir(), 'mcp.yaml'), - join(getGlobalConfigDir(), 'mcp.yml'), - ]; + const paths = [join(getGlobalConfigDir(), 'mcp.yaml'), join(getGlobalConfigDir(), 'mcp.yml')]; for (const p of paths) { if (existsSync(p)) { const raw = readFileSync(p, 'utf8'); @@ -118,7 +122,10 @@ export function setGlobalMcpDisabledState(serverName: string, disabled: boolean) // ---- 项目级 MCP disabled 状态:持久化到 .codingcode/config.yaml ---- -export function getProjectMcpDisabledState(projectRoot: string, serverName: string): boolean | undefined { +export function getProjectMcpDisabledState( + projectRoot: string, + serverName: string +): boolean | undefined { const p = join(projectRoot, '.codingcode', 'config.yaml'); if (!existsSync(p)) return undefined; try { @@ -131,7 +138,11 @@ export function getProjectMcpDisabledState(projectRoot: string, serverName: stri } } -export function setProjectMcpDisabledState(projectRoot: string, serverName: string, disabled: boolean): void { +export function setProjectMcpDisabledState( + projectRoot: string, + serverName: string, + disabled: boolean +): void { const dir = join(projectRoot, '.codingcode'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const p = join(dir, 'config.yaml'); @@ -149,7 +160,10 @@ export function setProjectMcpDisabledState(projectRoot: string, serverName: stri export function resetProjectMcpDisabledState(projectRoot: string, serverName: string): void { const p = join(projectRoot, '.codingcode', 'config.yaml'); if (!existsSync(p)) return; - const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record; + const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record< + string, + unknown + >; const mcp = (existing.mcp as Record) ?? {}; const disabledServers = mcp.disabledServers as Record; if (disabledServers) { diff --git a/packages/codingcode/src/runtime/project-runtime.ts b/packages/codingcode/src/runtime/project-runtime.ts index 960a26a..1234f14 100644 --- a/packages/codingcode/src/runtime/project-runtime.ts +++ b/packages/codingcode/src/runtime/project-runtime.ts @@ -51,7 +51,10 @@ export class ProjectRuntimeService extends Effect.Service cachedSubagentProfiles.set(norm, buildProfiles(norm)); }), - resolveMainAgentProfile: (projectPath: string, sessionId: string): AgentProfile | undefined => { + resolveMainAgentProfile: ( + projectPath: string, + sessionId: string + ): AgentProfile | undefined => { const sessionOverride = sessionAgentProfiles.get(sessionId); if (sessionOverride) return sessionOverride; return agentLoader.loadMainAgentProfile(projectPath); diff --git a/packages/codingcode/src/server/routes/settings.ts b/packages/codingcode/src/server/routes/settings.ts index e862e7c..c70a81b 100644 --- a/packages/codingcode/src/server/routes/settings.ts +++ b/packages/codingcode/src/server/routes/settings.ts @@ -295,17 +295,19 @@ settingsRouter.get('/agents', (c) => { const rawCwd = c.req.query('cwd'); if (isGlobalCwd(rawCwd)) { const custom = loadGlobalAgentProfiles(); - return c.json([EXPLORE_PROFILE, ...custom].map((a) => ({ - name: a.name, - description: a.description, - tools: a.tools, - mcpServers: a.mcpServers, - readonly: a.readonly, - maxSteps: a.maxSteps, - model: a.model, - disabled: getGlobalAgentDisabledState(a.name), - source: a.name === EXPLORE_PROFILE.name ? 'builtin' : 'global', - }))); + return c.json( + [EXPLORE_PROFILE, ...custom].map((a) => ({ + name: a.name, + description: a.description, + tools: a.tools, + mcpServers: a.mcpServers, + readonly: a.readonly, + maxSteps: a.maxSteps, + model: a.model, + disabled: getGlobalAgentDisabledState(a.name), + source: a.name === EXPLORE_PROFILE.name ? 'builtin' : 'global', + })) + ); } const cwd = resolveWorkspaceCwd(rawCwd); return c.json(agentsList(cwd)); @@ -383,10 +385,12 @@ settingsRouter.post('/agents/:name/disabled/reset', async (c) => { settingsRouter.get('/hooks', (c) => { const rawCwd = c.req.query('cwd'); if (isGlobalCwd(rawCwd)) { - return c.json(loadGlobalHookConfigs().map((h) => ({ - ...h, - source: 'global' as const, - }))); + return c.json( + loadGlobalHookConfigs().map((h) => ({ + ...h, + source: 'global' as const, + })) + ); } const cwd = resolveWorkspaceCwd(rawCwd); const globalHooks = loadGlobalHookConfigs(); @@ -394,17 +398,19 @@ settingsRouter.get('/hooks', (c) => { const globalNames = new Set(globalHooks.map((h) => h.name)); const projectNames = new Set(projectHooks.map((h) => h.name)); const merged = resolveHookConfigs(cwd); - return c.json(merged.map((h) => { - const isFromProject = projectNames.has(h.name); - const isFromGlobal = globalNames.has(h.name); - const hasProjectOverride = isFromProject && isFromGlobal; - return { - ...h, - source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', - hasProjectOverride, - disabled: resolveHookDisabled(cwd, h.name), - }; - })); + return c.json( + merged.map((h) => { + const isFromProject = projectNames.has(h.name); + const isFromGlobal = globalNames.has(h.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...h, + source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + hasProjectOverride, + disabled: resolveHookDisabled(cwd, h.name), + }; + }) + ); }); settingsRouter.post('/hooks', async (c) => { @@ -503,11 +509,13 @@ settingsRouter.post('/hooks/:name/disabled/reset', async (c) => { settingsRouter.get('/mcp', async (c) => { const rawCwd = c.req.query('cwd'); if (isGlobalCwd(rawCwd)) { - return c.json(loadGlobalMcpConfig().map((s) => ({ - ...s, - disabled: getGlobalMcpDisabledState(s.name), - source: 'global' as const, - }))); + return c.json( + loadGlobalMcpConfig().map((s) => ({ + ...s, + disabled: getGlobalMcpDisabledState(s.name), + source: 'global' as const, + })) + ); } const cwd = resolveWorkspaceCwd(rawCwd); const globalServers = loadGlobalMcpConfig(); @@ -515,17 +523,19 @@ settingsRouter.get('/mcp', async (c) => { const globalNames = new Set(globalServers.map((s) => s.name)); const projectNames = new Set(projectServers.map((s) => s.name)); const merged = resolveMcpConfig(cwd); - return c.json(merged.map((s) => { - const isFromProject = projectNames.has(s.name); - const isFromGlobal = globalNames.has(s.name); - const hasProjectOverride = isFromProject && isFromGlobal; - return { - ...s, - disabled: resolveMcpDisabled(cwd, s.name), - source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', - hasProjectOverride, - }; - })); + return c.json( + merged.map((s) => { + const isFromProject = projectNames.has(s.name); + const isFromGlobal = globalNames.has(s.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...s, + disabled: resolveMcpDisabled(cwd, s.name), + source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + hasProjectOverride, + }; + }) + ); }); settingsRouter.post('/mcp', async (c) => { @@ -619,10 +629,12 @@ settingsRouter.get('/skills', async (c) => { const { status, body } = errorResponse(result.error); return c.json(body, status as any); } - return c.json(result.value.map((s) => ({ - ...s, - source: 'global' as const, - }))); + return c.json( + result.value.map((s) => ({ + ...s, + source: 'global' as const, + })) + ); } const cwd = resolveWorkspaceCwd(rawCwd); const globalDirs = discoverGlobalSkillDirs(); @@ -639,16 +651,18 @@ settingsRouter.get('/skills', async (c) => { const { status, body } = errorResponse(result.error); return c.json(body, status as any); } - return c.json(result.value.map((s) => { - const isFromProject = projectNames.has(s.name); - const isFromGlobal = globalNames.has(s.name); - const hasProjectOverride = isFromProject && isFromGlobal; - return { - ...s, - source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', - hasProjectOverride, - }; - })); + return c.json( + result.value.map((s) => { + const isFromProject = projectNames.has(s.name); + const isFromGlobal = globalNames.has(s.name); + const hasProjectOverride = isFromProject && isFromGlobal; + return { + ...s, + source: isFromProject ? (hasProjectOverride ? 'global' : 'project') : 'global', + hasProjectOverride, + }; + }) + ); }); settingsRouter.post('/skills', async (c) => { diff --git a/packages/codingcode/src/skills/config.ts b/packages/codingcode/src/skills/config.ts index 7bd09a5..a5cb98d 100644 --- a/packages/codingcode/src/skills/config.ts +++ b/packages/codingcode/src/skills/config.ts @@ -138,7 +138,10 @@ export function setGlobalSkillDisabledState(skillName: string, disabled: boolean // ---- 项目级 Skill disabled 状态:持久化到 .codingcode/config.yaml ---- -export function getProjectSkillDisabledState(projectRoot: string, skillName: string): boolean | undefined { +export function getProjectSkillDisabledState( + projectRoot: string, + skillName: string +): boolean | undefined { const p = join(projectRoot, '.codingcode', 'config.yaml'); if (!existsSync(p)) return undefined; try { @@ -151,7 +154,11 @@ export function getProjectSkillDisabledState(projectRoot: string, skillName: str } } -export function setProjectSkillDisabledState(projectRoot: string, skillName: string, disabled: boolean): void { +export function setProjectSkillDisabledState( + projectRoot: string, + skillName: string, + disabled: boolean +): void { const dir = join(projectRoot, '.codingcode'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const p = join(dir, 'config.yaml'); @@ -169,7 +176,10 @@ export function setProjectSkillDisabledState(projectRoot: string, skillName: str export function resetProjectSkillDisabledState(projectRoot: string, skillName: string): void { const p = join(projectRoot, '.codingcode', 'config.yaml'); if (!existsSync(p)) return; - const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record; + const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record< + string, + unknown + >; const skills = (existing.skills as Record) ?? {}; const disabledSkills = skills.disabledSkills as Record; if (disabledSkills) { diff --git a/packages/codingcode/src/skills/index.ts b/packages/codingcode/src/skills/index.ts index 24ff490..6530267 100644 --- a/packages/codingcode/src/skills/index.ts +++ b/packages/codingcode/src/skills/index.ts @@ -49,7 +49,9 @@ export class SkillService extends Effect.Service()('Skill', { matcher: (all: readonly Skill[], q: string) => Effect.Effect ): Effect.Effect => Effect.gen(function* () { - const all = readAll(projectPath).filter((s) => !resolveSkillDisabled(projectPath, s.name)); + const all = readAll(projectPath).filter( + (s) => !resolveSkillDisabled(projectPath, s.name) + ); const name = yield* matcher(all, query); if (!name) return undefined; if (resolveSkillDisabled(projectPath, name)) return undefined; diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts index 4c8dd5f..e42bc7f 100644 --- a/packages/codingcode/src/subagent/registry.ts +++ b/packages/codingcode/src/subagent/registry.ts @@ -70,7 +70,10 @@ export function setProjectSubagentEnabledState(projectCwd: string, v: boolean): export function resetProjectSubagentEnabledState(projectCwd: string): void { const p = join(projectCwd, '.codingcode', 'config.yaml'); if (!existsSync(p)) return; - const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record; + const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record< + string, + unknown + >; const subagent = (existing.subagent as Record) ?? {}; delete subagent.enabled; if (Object.keys(subagent).length === 0) { @@ -117,7 +120,10 @@ export function setGlobalAgentDisabledState(agentName: string, disabled: boolean // ---- 项目级 agent disabled 状态:持久化到 .codingcode/config.yaml ---- -export function getProjectAgentDisabledState(projectCwd: string, agentName: string): boolean | undefined { +export function getProjectAgentDisabledState( + projectCwd: string, + agentName: string +): boolean | undefined { const p = join(projectCwd, '.codingcode', 'config.yaml'); if (!existsSync(p)) return undefined; try { @@ -130,7 +136,11 @@ export function getProjectAgentDisabledState(projectCwd: string, agentName: stri } } -export function setProjectAgentDisabledState(projectCwd: string, agentName: string, disabled: boolean): void { +export function setProjectAgentDisabledState( + projectCwd: string, + agentName: string, + disabled: boolean +): void { const dir = join(projectCwd, '.codingcode'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const p = join(dir, 'config.yaml'); @@ -148,7 +158,10 @@ export function setProjectAgentDisabledState(projectCwd: string, agentName: stri export function resetProjectAgentDisabledState(projectCwd: string, agentName: string): void { const p = join(projectCwd, '.codingcode', 'config.yaml'); if (!existsSync(p)) return; - const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record; + const existing: Record = parseYaml(readFileSync(p, 'utf8')) as Record< + string, + unknown + >; const subagent = (existing.subagent as Record) ?? {}; const disabledAgents = subagent.disabledAgents as Record; if (disabledAgents) { diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index 689a7c7..be962e8 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -29,25 +29,25 @@ export function createDispatchAgentTool(deps: DispatchAgentDeps): ToolDefinition prompt: z.string().min(1).describe('task description for the subagent'), }), execute: async (args: any, ctx: any) => { - const { agent: agentName, prompt } = args; + const { agent: agentName, prompt } = args; - const projectPath = ctx?.projectPath || process.cwd(); + const projectPath = ctx?.projectPath || process.cwd(); - // Check global subagent switch - if (!resolveSubagentEnabled(projectPath)) { - throw new Error('Subagent dispatch is disabled in global settings'); - } + // Check global subagent switch + if (!resolveSubagentEnabled(projectPath)) { + throw new Error('Subagent dispatch is disabled in global settings'); + } - // Get profile - const profile = deps.runtime.resolveSubagentProfile(projectPath, agentName); - if (!profile) { - throw new Error(`Unknown subagent: ${agentName}`); - } + // Get profile + const profile = deps.runtime.resolveSubagentProfile(projectPath, agentName); + if (!profile) { + throw new Error(`Unknown subagent: ${agentName}`); + } - // Check individual agent disabled state - if (resolveAgentDisabled(projectPath, agentName)) { - throw new Error(`Subagent '${agentName}' is disabled`); - } + // Check individual agent disabled state + if (resolveAgentDisabled(projectPath, agentName)) { + throw new Error(`Subagent '${agentName}' is disabled`); + } if (!ctx?.agentRunner?.agentService || !ctx?.agentRunner?.llm) { throw new Error('dispatch_agent requires agentRunner context'); diff --git a/packages/codingcode/test/agent/agent-concurrent.test.ts b/packages/codingcode/test/agent/agent-concurrent.test.ts index 4b0e8c7..f35cec5 100644 --- a/packages/codingcode/test/agent/agent-concurrent.test.ts +++ b/packages/codingcode/test/agent/agent-concurrent.test.ts @@ -68,6 +68,7 @@ const mockState = { title: 'test', usage: undefined, promptEstimate: 0, + memorySnapshot: '', }; function makeDeps(overrides?: Record) { diff --git a/packages/codingcode/test/agent/agent-todo-event.test.ts b/packages/codingcode/test/agent/agent-todo-event.test.ts index adcaaa0..4735054 100644 --- a/packages/codingcode/test/agent/agent-todo-event.test.ts +++ b/packages/codingcode/test/agent/agent-todo-event.test.ts @@ -66,6 +66,7 @@ const mockState = { title: 'test', usage: undefined, promptEstimate: 0, + memorySnapshot: '', }; const mockLlm = { diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index 6e3a650..5670615 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -66,6 +66,7 @@ const mockState = { title: 'test', usage: undefined, promptEstimate: 0, + memorySnapshot: '', }; function makeDeps(overrides?: Record) { diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index 9d13353..39fc0e3 100644 --- a/packages/codingcode/test/agent/hooks-deps-type.test.ts +++ b/packages/codingcode/test/agent/hooks-deps-type.test.ts @@ -55,6 +55,7 @@ const mockState = { title: 'type-test', usage: undefined, promptEstimate: 0, + memorySnapshot: '', }; describe('RunReActDeps hooks type', () => { diff --git a/packages/codingcode/test/agent/loop-options.test.ts b/packages/codingcode/test/agent/loop-options.test.ts index dd753b2..219e1bb 100644 --- a/packages/codingcode/test/agent/loop-options.test.ts +++ b/packages/codingcode/test/agent/loop-options.test.ts @@ -18,6 +18,7 @@ describe('runReActLoop 鈥?loop options', () => { indexPath: '', messageCount: 0, promptEstimate: 0, + memorySnapshot: '', }; const mockHooks = { diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index d63d87f..49d3888 100644 --- a/packages/codingcode/test/agent/memory-snapshot.test.ts +++ b/packages/codingcode/test/agent/memory-snapshot.test.ts @@ -133,7 +133,9 @@ describe('Memory snapshot stability', () => { const { llm, captured } = makeCapturingLlm(); await runOnce(llm, '## Long-term Memory\n\nOriginal snapshot'); expect(captured.system).toContain('Original snapshot'); - const lastUserMsg = [...(captured.messages ?? [])].reverse().find((m: any) => m.role === 'user'); + const lastUserMsg = [...(captured.messages ?? [])] + .reverse() + .find((m: any) => m.role === 'user'); expect(lastUserMsg).toBeDefined(); expect(lastUserMsg.content).toContain(''); expect(lastUserMsg.content).toContain('Updated on disk'); @@ -143,7 +145,9 @@ describe('Memory snapshot stability', () => { mockLoadMemoryForPrompt.mockReturnValue('## Long-term Memory\n\nSame'); const { llm, captured } = makeCapturingLlm(); await runOnce(llm, '## Long-term Memory\n\nSame'); - const lastUserMsg = [...(captured.messages ?? [])].reverse().find((m: any) => m.role === 'user'); + const lastUserMsg = [...(captured.messages ?? [])] + .reverse() + .find((m: any) => m.role === 'user'); expect(lastUserMsg).toBeDefined(); expect(lastUserMsg.content).not.toContain(''); }); @@ -152,7 +156,9 @@ describe('Memory snapshot stability', () => { mockLoadMemoryForPrompt.mockReturnValue(''); const { llm, captured } = makeCapturingLlm(); await runOnce(llm, ''); - const lastUserMsg = [...(captured.messages ?? [])].reverse().find((m: any) => m.role === 'user'); + const lastUserMsg = [...(captured.messages ?? [])] + .reverse() + .find((m: any) => m.role === 'user'); expect(lastUserMsg).toBeDefined(); expect(lastUserMsg.content).not.toContain(''); }); diff --git a/packages/codingcode/test/agent/stop-hook.test.ts b/packages/codingcode/test/agent/stop-hook.test.ts index 65c034b..0870d52 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.test.ts @@ -18,6 +18,7 @@ describe('runReActLoop 鈥?stop hook', () => { indexPath: '', messageCount: 0, promptEstimate: 0, + memorySnapshot: '', }; function baseMockDeps(overrides: Record = {}) { diff --git a/packages/codingcode/test/ci/tooling-scripts.test.ts b/packages/codingcode/test/ci/tooling-scripts.test.ts index 50d23be..4ff5095 100644 --- a/packages/codingcode/test/ci/tooling-scripts.test.ts +++ b/packages/codingcode/test/ci/tooling-scripts.test.ts @@ -35,7 +35,6 @@ describe('CI tooling configuration', () => { const content = readFileSync(workflowPath, 'utf8'); expect(content).toContain('jobs:'); expect(content).toContain('lint:'); - expect(content).toContain('format-check:'); expect(content).toContain('typecheck:'); expect(content).toContain('test:'); expect(content).toContain('build-desktop:'); diff --git a/packages/codingcode/test/client/direct/settings.test.ts b/packages/codingcode/test/client/direct/settings.test.ts index 1a29dce..4989d7c 100644 --- a/packages/codingcode/test/client/direct/settings.test.ts +++ b/packages/codingcode/test/client/direct/settings.test.ts @@ -31,7 +31,13 @@ vi.mock('../../../src/subagent/loader.js', () => ({ })); vi.mock('../../../src/subagent/registry.js', () => ({ - EXPLORE_PROFILE: { name: 'explore', description: 'Explore', tools: ['read_file'], readonly: true, maxSteps: 30 }, + EXPLORE_PROFILE: { + name: 'explore', + description: 'Explore', + tools: ['read_file'], + readonly: true, + maxSteps: 30, + }, setSubagentEnabledState: vi.fn(), resolveSubagentEnabled: vi.fn().mockReturnValue(true), getProjectSubagentEnabledState: vi.fn().mockReturnValue(undefined), @@ -73,10 +79,16 @@ vi.mock('../../../src/memory/index.js', () => ({ vi.mock('../../../src/core/error.js', () => ({ AlreadyExistsError: class AlreadyExistsError extends Error { - constructor(msg: string) { super(msg); this.name = 'AlreadyExistsError'; } + constructor(msg: string) { + super(msg); + this.name = 'AlreadyExistsError'; + } }, NotFoundError: class NotFoundError extends Error { - constructor(msg: string) { super(msg); this.name = 'NotFoundError'; } + constructor(msg: string) { + super(msg); + this.name = 'NotFoundError'; + } }, })); @@ -92,7 +104,8 @@ describe('createDirectSettingsClient - reset APIs', () => { describe('resetSubagentEnabled', () => { it('calls resetProjectSubagentEnabledState with cwd', async () => { - const { resetProjectSubagentEnabledState } = await import('../../../src/subagent/registry.js'); + const { resetProjectSubagentEnabledState } = + await import('../../../src/subagent/registry.js'); await client.resetSubagentEnabled({ cwd: '/my-project' }); expect(resetProjectSubagentEnabledState).toHaveBeenCalledWith('/my-project'); }); @@ -133,7 +146,8 @@ describe('createDirectSettingsClient - updated signatures with cwd', () => { describe('getSubagentEnabled', () => { it('returns enabled and source from resolveSubagentEnabled', async () => { - const { resolveSubagentEnabled, getProjectSubagentEnabledState } = await import('../../../src/subagent/registry.js'); + const { resolveSubagentEnabled, getProjectSubagentEnabledState } = + await import('../../../src/subagent/registry.js'); vi.mocked(resolveSubagentEnabled).mockReturnValue(true); vi.mocked(getProjectSubagentEnabledState).mockReturnValue(undefined); const result = await client.getSubagentEnabled({ cwd: '/my-project' }); @@ -142,7 +156,8 @@ describe('createDirectSettingsClient - updated signatures with cwd', () => { }); it('returns source=project when project override exists', async () => { - const { resolveSubagentEnabled, getProjectSubagentEnabledState } = await import('../../../src/subagent/registry.js'); + const { resolveSubagentEnabled, getProjectSubagentEnabledState } = + await import('../../../src/subagent/registry.js'); vi.mocked(resolveSubagentEnabled).mockReturnValue(false); vi.mocked(getProjectSubagentEnabledState).mockReturnValue(false); const result = await client.getSubagentEnabled({ cwd: '/my-project' }); diff --git a/packages/codingcode/test/client/http/settings.test.ts b/packages/codingcode/test/client/http/settings.test.ts index 910dfe3..154a36e 100644 --- a/packages/codingcode/test/client/http/settings.test.ts +++ b/packages/codingcode/test/client/http/settings.test.ts @@ -24,7 +24,7 @@ describe('createHttpSettingsClient - reset APIs', () => { await client.resetSubagentEnabled({ cwd: '/my-project' }); expect(request.apiPost).toHaveBeenCalledWith( '/api/settings/subagent/enabled/reset?cwd=%2Fmy-project', - {}, + {} ); }); }); @@ -34,7 +34,7 @@ describe('createHttpSettingsClient - reset APIs', () => { await client.resetAgentDisabled({ name: 'my-agent', cwd: '/my-project' }); expect(request.apiPost).toHaveBeenCalledWith( '/api/settings/agents/my-agent/disabled/reset?cwd=%2Fmy-project', - {}, + {} ); }); @@ -42,7 +42,7 @@ describe('createHttpSettingsClient - reset APIs', () => { await client.resetAgentDisabled({ name: 'my agent', cwd: '/my-project' }); expect(request.apiPost).toHaveBeenCalledWith( '/api/settings/agents/my%20agent/disabled/reset?cwd=%2Fmy-project', - {}, + {} ); }); }); @@ -52,7 +52,7 @@ describe('createHttpSettingsClient - reset APIs', () => { await client.resetMcpDisabled({ name: 'my-server', cwd: '/my-project' }); expect(request.apiPost).toHaveBeenCalledWith( '/api/settings/mcp/my-server/disabled/reset?cwd=%2Fmy-project', - {}, + {} ); }); }); @@ -62,7 +62,7 @@ describe('createHttpSettingsClient - reset APIs', () => { await client.resetHookDisabled({ name: 'my-hook', cwd: '/my-project' }); expect(request.apiPost).toHaveBeenCalledWith( '/api/settings/hooks/my-hook/disabled/reset?cwd=%2Fmy-project', - {}, + {} ); }); }); @@ -82,7 +82,7 @@ describe('createHttpSettingsClient - updated signatures with cwd', () => { request.apiGet.mockResolvedValue({ enabled: true, source: 'global' }); const result = await client.getSubagentEnabled({ cwd: '/my-project' }); expect(request.apiGet).toHaveBeenCalledWith( - '/api/settings/subagent/enabled?cwd=%2Fmy-project', + '/api/settings/subagent/enabled?cwd=%2Fmy-project' ); expect(result).toEqual({ enabled: true, source: 'global' }); }); @@ -93,7 +93,7 @@ describe('createHttpSettingsClient - updated signatures with cwd', () => { await client.setSubagentEnabled({ enabled: false, cwd: '/my-project' }); expect(request.apiPost).toHaveBeenCalledWith( '/api/settings/subagent/enabled?cwd=%2Fmy-project', - { enabled: false }, + { enabled: false } ); }); }); @@ -103,7 +103,7 @@ describe('createHttpSettingsClient - updated signatures with cwd', () => { await client.setMcpDisabled({ name: 'my-server', disabled: true, cwd: '/my-project' }); expect(request.apiPost).toHaveBeenCalledWith( '/api/settings/mcp/my-server/disabled?cwd=%2Fmy-project', - { disabled: true }, + { disabled: true } ); }); }); @@ -113,7 +113,7 @@ describe('createHttpSettingsClient - updated signatures with cwd', () => { await client.setAgentDisabled({ name: 'my-agent', disabled: true, cwd: '/my-project' }); expect(request.apiPost).toHaveBeenCalledWith( '/api/settings/agents/my-agent/disabled?cwd=%2Fmy-project', - { disabled: true }, + { disabled: true } ); }); }); @@ -123,7 +123,7 @@ describe('createHttpSettingsClient - updated signatures with cwd', () => { await client.setHookDisabled({ cwd: '/my-project', name: 'my-hook', disabled: true }); expect(request.apiPost).toHaveBeenCalledWith( '/api/settings/hooks/my-hook/disabled?cwd=%2Fmy-project', - { disabled: true }, + { disabled: true } ); }); }); @@ -131,10 +131,10 @@ describe('createHttpSettingsClient - updated signatures with cwd', () => { describe('toggleSkill', () => { it('POSTs to /settings/skills with cwd query param', async () => { await client.toggleSkill({ name: 'my-skill', enabled: true, cwd: '/my-project' }); - expect(request.apiPost).toHaveBeenCalledWith( - '/api/settings/skills?cwd=%2Fmy-project', - { name: 'my-skill', enabled: true }, - ); + expect(request.apiPost).toHaveBeenCalledWith('/api/settings/skills?cwd=%2Fmy-project', { + name: 'my-skill', + enabled: true, + }); }); }); }); diff --git a/packages/codingcode/test/context/context.test.ts b/packages/codingcode/test/context/context.test.ts index c7b11fc..494a6c5 100644 --- a/packages/codingcode/test/context/context.test.ts +++ b/packages/codingcode/test/context/context.test.ts @@ -69,8 +69,9 @@ const MockContextLayer = Layer.succeed( build: () => Effect.sync(() => ({ messages: [{ role: 'user' as const, content: 'hi' }], - newBudgets: [], + compactedEvents: [], promptEstimate: 0, + currentTurnId: 0, })), compress: () => Effect.succeed({ didCompress: true, released: 0, promptEstimate: 0 }), compactIfNeeded: () => Effect.succeed({ didCompress: false, released: 0, promptEstimate: 0 }), diff --git a/packages/codingcode/test/hooks/config-merge.test.ts b/packages/codingcode/test/hooks/config-merge.test.ts index b5cbaba..6298d49 100644 --- a/packages/codingcode/test/hooks/config-merge.test.ts +++ b/packages/codingcode/test/hooks/config-merge.test.ts @@ -19,37 +19,72 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); const TEST_PROJECT_DIR = join(__dirname, '..', '..', '..', 'test-fixture-hooks-merge'); const TEST_PROJECT_CODINGCODE = join(TEST_PROJECT_DIR, '.codingcode'); -const TEST_GLOBAL_DIR = join(__dirname, '..', '..', '..', 'test-fixture-global-hooks', '.codingcode'); +const TEST_GLOBAL_DIR = join( + __dirname, + '..', + '..', + '..', + 'test-fixture-global-hooks', + '.codingcode' +); describe('Hooks config merge', () => { beforeEach(() => { - if (existsSync(TEST_PROJECT_DIR)) - rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + if (existsSync(TEST_PROJECT_DIR)) rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); mkdirSync(TEST_PROJECT_CODINGCODE, { recursive: true }); if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'))) - rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'), { recursive: true, force: true }); + rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'), { + recursive: true, + force: true, + }); mkdirSync(TEST_GLOBAL_DIR, { recursive: true }); }); afterEach(() => { - if (existsSync(TEST_PROJECT_DIR)) - rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + if (existsSync(TEST_PROJECT_DIR)) rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'))) - rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'), { recursive: true, force: true }); + rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global-hooks'), { + recursive: true, + force: true, + }); }); it('should merge global and project hooks, project overrides global', () => { // Write global hooks const globalHooks = [ - { name: 'global-hook', point: 'tool.execute.before', type: 'observer' as const, command: 'global-cmd', enabled: true }, - { name: 'shared-hook', point: 'tool.execute.after', type: 'observer' as const, command: 'global-shared-cmd', enabled: true }, + { + name: 'global-hook', + point: 'tool.execute.before' as const, + type: 'observer' as const, + command: 'global-cmd', + enabled: true, + }, + { + name: 'shared-hook', + point: 'tool.execute.after' as const, + type: 'observer' as const, + command: 'global-shared-cmd', + enabled: true, + }, ]; writeGlobalHookConfigs(globalHooks); // Write project hooks const projectHooks = [ - { name: 'shared-hook', point: 'tool.execute.after', type: 'observer' as const, command: 'project-shared-cmd', enabled: true }, - { name: 'project-hook', point: 'tool.execute.error', type: 'observer' as const, command: 'project-cmd', enabled: true }, + { + name: 'shared-hook', + point: 'tool.execute.after' as const, + type: 'observer' as const, + command: 'project-shared-cmd', + enabled: true, + }, + { + name: 'project-hook', + point: 'tool.execute.error' as const, + type: 'observer' as const, + command: 'project-cmd', + enabled: true, + }, ]; writeHookConfigs(TEST_PROJECT_DIR, projectHooks); diff --git a/packages/codingcode/test/mcp/config-merge.test.ts b/packages/codingcode/test/mcp/config-merge.test.ts index abf6389..df40872 100644 --- a/packages/codingcode/test/mcp/config-merge.test.ts +++ b/packages/codingcode/test/mcp/config-merge.test.ts @@ -14,6 +14,7 @@ import { setProjectMcpDisabledState, resetProjectMcpDisabledState, resolveMcpDisabled, + _setGlobalConfigDir, } from '../../src/mcp/config.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -22,35 +23,66 @@ const TEST_PROJECT_CODINGCODE = join(TEST_PROJECT_DIR, '.codingcode'); // 模拟全局目录 const TEST_GLOBAL_DIR = join(__dirname, '..', '..', '..', 'test-fixture-global', '.codingcode'); +const TEST_GLOBAL_PARENT = join(__dirname, '..', '..', '..', 'test-fixture-global'); describe('MCP config merge', () => { beforeEach(() => { - if (existsSync(TEST_PROJECT_DIR)) - rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + _setGlobalConfigDir(TEST_GLOBAL_PARENT); + if (existsSync(TEST_PROJECT_DIR)) rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); mkdirSync(TEST_PROJECT_CODINGCODE, { recursive: true }); if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global'))) - rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global'), { recursive: true, force: true }); + rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global'), { + recursive: true, + force: true, + }); mkdirSync(TEST_GLOBAL_DIR, { recursive: true }); }); afterEach(() => { - if (existsSync(TEST_PROJECT_DIR)) - rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); + _setGlobalConfigDir(undefined); + if (existsSync(TEST_PROJECT_DIR)) rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); if (existsSync(join(__dirname, '..', '..', '..', 'test-fixture-global'))) - rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global'), { recursive: true, force: true }); + rmSync(join(__dirname, '..', '..', '..', 'test-fixture-global'), { + recursive: true, + force: true, + }); }); it('should merge global and project configs, project overrides global', () => { // Write global config writeGlobalMcpConfig([ - { name: 'global-server', transport: 'stdio', command: 'global-cmd', disabled: false, toolCount: 0 } as any, - { name: 'shared-server', transport: 'stdio', command: 'global-shared-cmd', disabled: false, toolCount: 0 } as any, + { + name: 'global-server', + transport: 'stdio', + command: 'global-cmd', + disabled: false, + toolCount: 0, + } as any, + { + name: 'shared-server', + transport: 'stdio', + command: 'global-shared-cmd', + disabled: false, + toolCount: 0, + } as any, ]); // Write project config writeMcpConfig(TEST_PROJECT_DIR, [ - { name: 'shared-server', transport: 'stdio', command: 'project-shared-cmd', disabled: false, toolCount: 0 } as any, - { name: 'project-server', transport: 'stdio', command: 'project-cmd', disabled: false, toolCount: 0 } as any, + { + name: 'shared-server', + transport: 'stdio', + command: 'project-shared-cmd', + disabled: false, + toolCount: 0, + } as any, + { + name: 'project-server', + transport: 'stdio', + command: 'project-cmd', + disabled: false, + toolCount: 0, + } as any, ]); const merged = resolveMcpConfig(TEST_PROJECT_DIR); @@ -73,41 +105,50 @@ describe('MCP config merge', () => { it('should return only project config when no global config', () => { writeMcpConfig(TEST_PROJECT_DIR, [ - { name: 'project-server', transport: 'stdio', command: 'project-cmd', disabled: false, toolCount: 0 } as any, + { + name: 'project-server', + transport: 'stdio', + command: 'project-cmd', + disabled: false, + toolCount: 0, + } as any, ]); const merged = resolveMcpConfig(TEST_PROJECT_DIR); expect(merged).toHaveLength(1); - expect(merged[0].name).toBe('project-server'); + expect(merged[0]!.name).toBe('project-server'); }); it('should return only global config when no project config', () => { writeGlobalMcpConfig([ - { name: 'global-server', transport: 'stdio', command: 'global-cmd', disabled: false, toolCount: 0 } as any, + { + name: 'global-server', + transport: 'stdio', + command: 'global-cmd', + disabled: false, + toolCount: 0, + } as any, ]); const merged = resolveMcpConfig(TEST_PROJECT_DIR); expect(merged).toHaveLength(1); - expect(merged[0].name).toBe('global-server'); + expect(merged[0]!.name).toBe('global-server'); }); }); -// Helper to write global config to test directory -function writeGlobalMcpConfig(servers: any[]): void { - // Override the global config dir by writing to the test fixture - const p = join(TEST_GLOBAL_DIR, 'mcp.yaml'); - writeFileSync(p, `servers:\n${servers.map((s) => ` - name: ${s.name}\n transport: ${s.transport}\n command: ${s.command}\n`).join('')}`, 'utf8'); -} - describe('MCP disabled state', () => { const testServer = '__test_mcp_server__'; beforeEach(() => { + _setGlobalConfigDir(TEST_GLOBAL_PARENT); mkdirSync(TEST_PROJECT_CODINGCODE, { recursive: true }); + if (existsSync(TEST_GLOBAL_DIR)) rmSync(TEST_GLOBAL_DIR, { recursive: true, force: true }); + mkdirSync(TEST_GLOBAL_DIR, { recursive: true }); setGlobalMcpDisabledState(testServer, false); }); afterEach(() => { + _setGlobalConfigDir(undefined); rmSync(TEST_PROJECT_DIR, { recursive: true, force: true }); setGlobalMcpDisabledState(testServer, false); }); diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index 3639ce9..2adc01c 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -22,6 +22,7 @@ const mockState = { title: 'test-sess', usage: undefined, promptEstimate: 0, + memorySnapshot: '', }; const mockLlm = { @@ -102,6 +103,7 @@ const MockSessionLayer = Layer.succeed( uuid: 's1', replaces: [], summaryText: '', + lastSummarizedTurnId: 0, timestamp: new Date().toISOString(), }), hideMessage: () => @@ -150,8 +152,9 @@ const MockContextLayer = Layer.succeed( build: () => Effect.sync(() => ({ messages: [{ role: 'user' as const, content: 'hi' }], - newBudgets: [], + compactedEvents: [], promptEstimate: 0, + currentTurnId: 0, })), compress: () => Effect.succeed({ didCompress: true, released: 0, promptEstimate: 0 }), compactIfNeeded: () => Effect.succeed({ didCompress: false, released: 0, promptEstimate: 0 }), diff --git a/packages/codingcode/test/server/handler.test.ts b/packages/codingcode/test/server/handler.test.ts index e828573..e0c001a 100644 --- a/packages/codingcode/test/server/handler.test.ts +++ b/packages/codingcode/test/server/handler.test.ts @@ -24,6 +24,7 @@ const mockState = { title: 'test-sess', usage: undefined, promptEstimate: 0, + memorySnapshot: '', }; function createMockLlm(chunks?: string[], responseContent?: string) { @@ -109,6 +110,7 @@ const MockSessionLayer = Layer.succeed( uuid: 's1', replaces: [], summaryText: '', + lastSummarizedTurnId: 0, timestamp: new Date().toISOString(), }), hideMessage: () => @@ -157,8 +159,9 @@ const MockContextLayer = Layer.succeed( build: () => Effect.sync(() => ({ messages: [{ role: 'user' as const, content: 'hi' }], - newBudgets: [], + compactedEvents: [], promptEstimate: 0, + currentTurnId: 0, })), compress: () => Effect.succeed({ didCompress: true, released: 0, promptEstimate: 0 }), compactIfNeeded: () => Effect.succeed({ didCompress: false, released: 0, promptEstimate: 0 }), diff --git a/packages/codingcode/test/server/settings-routes.test.ts b/packages/codingcode/test/server/settings-routes.test.ts index d47274e..60d5b94 100644 --- a/packages/codingcode/test/server/settings-routes.test.ts +++ b/packages/codingcode/test/server/settings-routes.test.ts @@ -25,7 +25,13 @@ vi.mock('../../src/memory/index.js', () => { }); vi.mock('../../src/subagent/registry.js', () => ({ - EXPLORE_PROFILE: { name: 'explore', description: 'Explore', tools: ['read_file'], readonly: true, maxSteps: 30 }, + EXPLORE_PROFILE: { + name: 'explore', + description: 'Explore', + tools: ['read_file'], + readonly: true, + maxSteps: 30, + }, resolveSubagentEnabled: vi.fn().mockReturnValue(true), getProjectSubagentEnabledState: vi.fn().mockReturnValue(undefined), setProjectSubagentEnabledState: vi.fn(), @@ -97,10 +103,16 @@ vi.mock('../../src/core/workspace.js', () => ({ vi.mock('../../src/core/error.js', () => ({ AlreadyExistsError: class AlreadyExistsError extends Error { - constructor(msg: string) { super(msg); this.name = 'AlreadyExistsError'; } + constructor(msg: string) { + super(msg); + this.name = 'AlreadyExistsError'; + } }, NotFoundError: class NotFoundError extends Error { - constructor(msg: string) { super(msg); this.name = 'NotFoundError'; } + constructor(msg: string) { + super(msg); + this.name = 'NotFoundError'; + } }, })); @@ -190,8 +202,10 @@ describe('GET /agents', () => { }); it('returns project agents with merged view', async () => { - const { loadGlobalAgentProfiles, loadAgentProfiles } = await import('../../src/subagent/loader.js'); - const { getProjectAgentDisabledState, resolveAgentDisabled } = await import('../../src/subagent/registry.js'); + const { loadGlobalAgentProfiles, loadAgentProfiles } = + await import('../../src/subagent/loader.js'); + const { getProjectAgentDisabledState, resolveAgentDisabled } = + await import('../../src/subagent/registry.js'); vi.mocked(loadGlobalAgentProfiles).mockReturnValue([ { name: 'global-agent', description: 'Global', tools: ['read_file'] }, ]); @@ -267,7 +281,8 @@ describe('GET /subagent/enabled', () => { }); it('returns project enabled state with source=project when project override exists', async () => { - const { getProjectSubagentEnabledState, resolveSubagentEnabled } = await import('../../src/subagent/registry.js'); + const { getProjectSubagentEnabledState, resolveSubagentEnabled } = + await import('../../src/subagent/registry.js'); vi.mocked(getProjectSubagentEnabledState).mockReturnValue(false); vi.mocked(resolveSubagentEnabled).mockReturnValue(false); const res = await settingsRouter.request('/subagent/enabled?cwd=/my-project'); @@ -278,7 +293,8 @@ describe('GET /subagent/enabled', () => { }); it('returns source=global when no project override', async () => { - const { getProjectSubagentEnabledState, resolveSubagentEnabled } = await import('../../src/subagent/registry.js'); + const { getProjectSubagentEnabledState, resolveSubagentEnabled } = + await import('../../src/subagent/registry.js'); vi.mocked(getProjectSubagentEnabledState).mockReturnValue(undefined); vi.mocked(resolveSubagentEnabled).mockReturnValue(true); const res = await settingsRouter.request('/subagent/enabled?cwd=/my-project'); @@ -334,10 +350,9 @@ describe('GET /mcp', () => { }); it('returns global MCP servers with source and disabled', async () => { - const { loadGlobalMcpConfig, getGlobalMcpDisabledState } = await import('../../src/mcp/config.js'); - vi.mocked(loadGlobalMcpConfig).mockReturnValue([ - { name: 'server1', command: 'npx' }, - ]); + const { loadGlobalMcpConfig, getGlobalMcpDisabledState } = + await import('../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([{ name: 'server1', command: 'npx' }]); vi.mocked(getGlobalMcpDisabledState).mockReturnValue(false); const res = await settingsRouter.request('/mcp?cwd=global'); expect(res.status).toBe(200); @@ -348,13 +363,10 @@ describe('GET /mcp', () => { }); it('returns project MCP servers with merged view', async () => { - const { loadGlobalMcpConfig, loadMcpConfig, resolveMcpConfig, resolveMcpDisabled } = await import('../../src/mcp/config.js'); - vi.mocked(loadGlobalMcpConfig).mockReturnValue([ - { name: 'global-srv', command: 'npx' }, - ]); - vi.mocked(loadMcpConfig).mockReturnValue([ - { name: 'project-srv', command: 'node' }, - ]); + const { loadGlobalMcpConfig, loadMcpConfig, resolveMcpConfig, resolveMcpDisabled } = + await import('../../src/mcp/config.js'); + vi.mocked(loadGlobalMcpConfig).mockReturnValue([{ name: 'global-srv', command: 'npx' }]); + vi.mocked(loadMcpConfig).mockReturnValue([{ name: 'project-srv', command: 'node' }]); vi.mocked(resolveMcpConfig).mockReturnValue([ { name: 'global-srv', command: 'npx' }, { name: 'project-srv', command: 'node' }, @@ -417,7 +429,13 @@ describe('GET /hooks', () => { it('returns global hooks with source field', async () => { const { loadGlobalHookConfigs } = await import('../../src/hooks/config.js'); vi.mocked(loadGlobalHookConfigs).mockReturnValue([ - { name: 'hook1', point: 'pre_tool_use', type: 'observer', command: 'echo', enabled: true }, + { + name: 'hook1', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, ]); const res = await settingsRouter.request('/hooks?cwd=global'); expect(res.status).toBe(200); @@ -427,16 +445,41 @@ describe('GET /hooks', () => { }); it('returns project hooks with merged view', async () => { - const { loadGlobalHookConfigs, loadHookConfigs, resolveHookConfigs, resolveHookDisabled } = await import('../../src/hooks/config.js'); + const { loadGlobalHookConfigs, loadHookConfigs, resolveHookConfigs, resolveHookDisabled } = + await import('../../src/hooks/config.js'); vi.mocked(loadGlobalHookConfigs).mockReturnValue([ - { name: 'global-hook', point: 'pre_tool_use', type: 'observer', command: 'echo', enabled: true }, + { + name: 'global-hook', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, ]); vi.mocked(loadHookConfigs).mockReturnValue([ - { name: 'project-hook', point: 'post_tool_use', type: 'decision', command: 'sh', enabled: true }, + { + name: 'project-hook', + point: 'tool.execute.after', + type: 'decision', + command: 'sh', + enabled: true, + }, ]); vi.mocked(resolveHookConfigs).mockReturnValue([ - { name: 'global-hook', point: 'pre_tool_use', type: 'observer', command: 'echo', enabled: true }, - { name: 'project-hook', point: 'post_tool_use', type: 'decision', command: 'sh', enabled: true }, + { + name: 'global-hook', + point: 'tool.execute.before', + type: 'observer', + command: 'echo', + enabled: true, + }, + { + name: 'project-hook', + point: 'tool.execute.after', + type: 'decision', + command: 'sh', + enabled: true, + }, ]); vi.mocked(resolveHookDisabled).mockReturnValue(false); const res = await settingsRouter.request('/hooks?cwd=/my-project'); diff --git a/packages/codingcode/test/session/view-assembly.test.ts b/packages/codingcode/test/session/view-assembly.test.ts index f3660b8..02bbf5d 100644 --- a/packages/codingcode/test/session/view-assembly.test.ts +++ b/packages/codingcode/test/session/view-assembly.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { randomUUID } from 'crypto'; import { buildMessagesFromEvents } from '../../src/session/messages.js'; import type { SessionEvent } from '../../src/session/types.js'; @@ -96,6 +96,7 @@ describe('buildMessagesFromEvents', () => { uuid: 's1', replaces: ['t1'], summaryText: '[compacted]', + lastSummarizedTurnId: 1, timestamp: new Date().toISOString(), } as any, ]); @@ -333,6 +334,7 @@ describe('buildMessagesFromEvents', () => { uuid: 's1', replaces: ['t1'], summaryText: '[compacted]', + lastSummarizedTurnId: 1, timestamp: new Date().toISOString(), }, { type: 'user', turnId: 2, uuid: 'u2', content: 'next', timestamp: new Date().toISOString() }, diff --git a/packages/codingcode/test/subagent/switch.test.ts b/packages/codingcode/test/subagent/switch.test.ts index 5b98050..adba927 100644 --- a/packages/codingcode/test/subagent/switch.test.ts +++ b/packages/codingcode/test/subagent/switch.test.ts @@ -53,7 +53,12 @@ describe('Subagent switch', () => { { name: 'disabled-agent', description: 'I am disabled', disabled: true }, ]; - const prompt = buildSystemPrompt({ agentProfiles: profiles }); + const prompt = buildSystemPrompt({ + cwd: '/tmp', + platform: 'linux', + shell: 'bash', + agentProfiles: profiles, + }); expect(prompt).toContain('enabled-agent'); expect(prompt).not.toContain('disabled-agent'); @@ -64,7 +69,12 @@ describe('Subagent switch', () => { { name: 'disabled-agent', description: 'I am disabled', disabled: true }, ]; - const prompt = buildSystemPrompt({ agentProfiles: profiles }); + const prompt = buildSystemPrompt({ + cwd: '/tmp', + platform: 'linux', + shell: 'bash', + agentProfiles: profiles, + }); expect(prompt).not.toContain('Available Subagents'); }); @@ -75,20 +85,30 @@ describe('Subagent switch', () => { { name: 'disabled-agent', description: 'I am disabled', disabled: true }, ]; - const prompt = buildSystemPrompt({ agentProfiles: profiles }); + const prompt = buildSystemPrompt({ + cwd: '/tmp', + platform: 'linux', + shell: 'bash', + agentProfiles: profiles, + }); expect(prompt).toContain('Available Subagents'); }); it('should not inject Available Subagents when no profiles provided', () => { - const prompt = buildSystemPrompt({}); + const prompt = buildSystemPrompt({ cwd: '/tmp', platform: 'linux', shell: 'bash' }); expect(prompt).not.toContain('Available Subagents'); }); it('should not inject Available Subagents when subagent switch is off (empty profiles)', () => { // Simulates agent.ts logic: when resolveSubagentEnabled is false, agentProfiles = [] - const prompt = buildSystemPrompt({ agentProfiles: [] }); + const prompt = buildSystemPrompt({ + cwd: '/tmp', + platform: 'linux', + shell: 'bash', + agentProfiles: [], + }); expect(prompt).not.toContain('Available Subagents'); }); @@ -102,7 +122,12 @@ describe('Subagent switch', () => { // Simulate agent-b being disabled via resolveAgentDisabled const filteredProfiles = allProfiles.filter((p) => p.name !== 'agent-b'); - const prompt = buildSystemPrompt({ agentProfiles: filteredProfiles }); + const prompt = buildSystemPrompt({ + cwd: '/tmp', + platform: 'linux', + shell: 'bash', + agentProfiles: filteredProfiles, + }); expect(prompt).toContain('agent-a'); expect(prompt).not.toContain('agent-b'); diff --git a/packages/desktop/src/agent/AgentSidebar.tsx b/packages/desktop/src/agent/AgentSidebar.tsx index 252a67e..1a703c9 100644 --- a/packages/desktop/src/agent/AgentSidebar.tsx +++ b/packages/desktop/src/agent/AgentSidebar.tsx @@ -179,7 +179,15 @@ export default function AgentSidebar() { ); } -function NavItem({ icon, label, shortcut }: { icon: React.ReactNode; label: string; shortcut?: string }) { +function NavItem({ + icon, + label, + shortcut, +}: { + icon: React.ReactNode; + label: string; + shortcut?: string; +}) { return (
- 已编辑 {diff.files.length} 个文件 - {isInterrupted && (对话中断)} + + 已编辑 {diff.files.length} 个文件 + + {isInterrupted && ( + (对话中断) + )}
{totalInsertions > 0 && ( +{totalInsertions} @@ -151,12 +160,18 @@ function TurnDiffPanel({ {f.source === 'agent' ? 'Agent' : '未知'} - {isReverted && 已回退} + {isReverted && ( + 已回退 + )}
- {f.insertions > 0 && +{f.insertions}} - {f.deletions > 0 && -{f.deletions}} + {f.insertions > 0 && ( + +{f.insertions} + )} + {f.deletions > 0 && ( + -{f.deletions} + )}
) : ( - {relativeTime(t.updatedAt)} + + {relativeTime(t.updatedAt)} + )} ))} @@ -110,7 +112,9 @@ function SessionListPopup({
暂无对话
)} {sorted.length > 12 && ( -
+{sorted.length - 12} 条更多
+
+ +{sorted.length - 12} 条更多 +
)}
@@ -255,9 +259,11 @@ export default function ProjectStrip() { title={sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'} className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-placeholder)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] transition-colors shrink-0" > - {sidebarCollapsed - ? - : } + {sidebarCollapsed ? ( + + ) : ( + + )} ); diff --git a/packages/desktop/src/agent/TodoPanel.tsx b/packages/desktop/src/agent/TodoPanel.tsx index a7a71cf..9029b74 100644 --- a/packages/desktop/src/agent/TodoPanel.tsx +++ b/packages/desktop/src/agent/TodoPanel.tsx @@ -17,7 +17,13 @@ function TodoItemRow({ item }: { item: TodoItem }) { return (
{statusIcon} - + {item.step}
@@ -66,7 +72,9 @@ export default function TodoPanel({ threadId }: { threadId: string }) { {/* Expanded list */} {!collapsed && (
- {allCompleted &&
全部完成
} + {allCompleted && ( +
全部完成
+ )}
{items.map((item, index) => ( diff --git a/packages/desktop/src/lib/core-api.ts b/packages/desktop/src/lib/core-api.ts index 133cb4a..747f0b0 100644 --- a/packages/desktop/src/lib/core-api.ts +++ b/packages/desktop/src/lib/core-api.ts @@ -91,8 +91,8 @@ export function deleteMemoryExtraType(name: string): Promise { // ---- Settings: MCP ---- -export function listMcpServers(cwd?: string): Promise { - return clients.settings.getMcpStatus({ cwd: cwd ?? '' }); +export function listMcpServers(_cwd?: string): Promise { + return clients.settings.getMcpStatus(); } export function setMcpDisabled(name: string, disabled: boolean, cwd?: string): Promise { @@ -157,7 +157,9 @@ export function deleteAgent(cwd: string | undefined, name: string): Promise { +export async function getSubagentEnabled( + cwd?: string +): Promise<{ enabled: boolean; source: string }> { return clients.settings.getSubagentEnabled({ cwd: cwd ?? '' }); } @@ -171,10 +173,10 @@ export function resetSubagentEnabled(cwd: string): Promise { // ---- Settings: Skills ---- -export function listSkills(cwd?: string): Promise< - Array<{ name: string; description: string; disabled: boolean }> -> { - return clients.settings.listSkills({ cwd: cwd ?? '' }) as any; +export function listSkills( + _cwd?: string +): Promise> { + return clients.settings.listSkills() as any; } export function toggleSkill(name: string, enabled: boolean, cwd?: string): Promise { diff --git a/packages/desktop/src/settings/HooksPanel.tsx b/packages/desktop/src/settings/HooksPanel.tsx index c2b74a6..35ed3b6 100644 --- a/packages/desktop/src/settings/HooksPanel.tsx +++ b/packages/desktop/src/settings/HooksPanel.tsx @@ -1,7 +1,14 @@ import { useState, useEffect } from 'react'; import Toggle from './Toggle'; import { useGlobalStore } from '../stores/global.store'; -import { listHooks, createHook, updateHook, deleteHook, setHookDisabled, resetHookDisabled } from '../lib/core-api'; +import { + listHooks, + createHook, + updateHook, + deleteHook, + setHookDisabled, + resetHookDisabled, +} from '../lib/core-api'; interface HookEntry { name: string; @@ -204,9 +211,12 @@ export default function HooksPanel({ global: isGlobal }: { global?: boolean }) { const inputCls = 'w-full bg-[var(--bg-hover)] border border-[var(--border-hover)] text-[var(--text-title)] px-3 py-2 rounded text-[13px] focus:outline-none focus:ring-1 focus:ring-[var(--accent-primary)]'; const labelCls = 'text-[12px] text-[var(--text-placeholder)] mb-1'; - const btnPrimary = 'px-4 py-2 rounded text-[13px] bg-[var(--btn-primary-bg)] text-[var(--accent-primary)] hover:bg-[var(--btn-primary-hover)]'; - const btnDanger = 'px-4 py-2 rounded text-[13px] bg-[var(--btn-danger-bg)] text-[var(--accent-danger)] hover:bg-[var(--btn-danger-hover)]'; - const btnCancel = 'px-4 py-2 rounded text-[13px] bg-[var(--border-card)] text-[var(--text-tertiary)] border border-[var(--border-hover)] hover:bg-[var(--border-hover)] hover:border-[var(--border-strong)]'; + const btnPrimary = + 'px-4 py-2 rounded text-[13px] bg-[var(--btn-primary-bg)] text-[var(--accent-primary)] hover:bg-[var(--btn-primary-hover)]'; + const btnDanger = + 'px-4 py-2 rounded text-[13px] bg-[var(--btn-danger-bg)] text-[var(--accent-danger)] hover:bg-[var(--btn-danger-hover)]'; + const btnCancel = + 'px-4 py-2 rounded text-[13px] bg-[var(--border-card)] text-[var(--text-tertiary)] border border-[var(--border-hover)] hover:bg-[var(--border-hover)] hover:border-[var(--border-strong)]'; if (loading) { return
加载中…
; @@ -268,7 +278,9 @@ export default function HooksPanel({ global: isGlobal }: { global?: boolean }) { key={h.name} className="flex items-center justify-between px-4 py-3.5 rounded-xl bg-[var(--bg-card)] border border-[var(--btn-danger-bg)]" > - 删除钩子 {h.name}? + + 删除钩子 {h.name}? +
{h.description && ( -
{h.description}
+
+ {h.description} +
)} -
{h.command}
+
+ {h.command} +
; @@ -251,7 +254,9 @@ export default function McpPanel({ global: isGlobal }: { global?: boolean }) { key={s.name} className="flex items-center justify-between px-4 py-3.5 rounded-xl bg-[var(--bg-card)] border border-[var(--btn-danger-bg)]" > - 删除服务器 {s.name}? + + 删除服务器 {s.name}? +
-
{s.toolCount} 个工具
+
+ {s.toolCount} 个工具 +