mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41289c960b | ||
|
|
33b293271e | ||
|
|
cbcf239a0a | ||
|
|
c702cf88e2 | ||
|
|
8a4d23c22a | ||
|
|
042cec6065 | ||
|
|
39fee48c0b |
@@ -0,0 +1,4 @@
|
||||
provider: github
|
||||
owner: davidkaya
|
||||
repo: aryx
|
||||
releaseType: release
|
||||
@@ -21,6 +21,24 @@ internal sealed class CopilotApprovalCoordinator
|
||||
private const string HookPermissionKind = "hook";
|
||||
private const string ToolCallingActivityType = "tool-calling";
|
||||
|
||||
private static readonly Dictionary<string, string> HookToolCategories = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["view"] = ReadPermissionKind,
|
||||
["glob"] = ReadPermissionKind,
|
||||
["grep"] = ReadPermissionKind,
|
||||
["lsp"] = ReadPermissionKind,
|
||||
["edit"] = WritePermissionKind,
|
||||
["create"] = WritePermissionKind,
|
||||
["powershell"] = ShellPermissionKind,
|
||||
["read_powershell"] = ShellPermissionKind,
|
||||
["write_powershell"] = ShellPermissionKind,
|
||||
["stop_powershell"] = ShellPermissionKind,
|
||||
["list_powershell"] = ShellPermissionKind,
|
||||
["web_fetch"] = UrlPermissionKind,
|
||||
["web_search"] = UrlPermissionKind,
|
||||
["store_memory"] = MemoryPermissionKind,
|
||||
};
|
||||
|
||||
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _requestApprovedTools = new(StringComparer.Ordinal);
|
||||
|
||||
@@ -140,6 +158,16 @@ internal sealed class CopilotApprovalCoordinator
|
||||
string permissionKind = string.IsNullOrWhiteSpace(request.Kind)
|
||||
? "tool access"
|
||||
: request.Kind.Trim();
|
||||
|
||||
if (request is PermissionRequestHook hook)
|
||||
{
|
||||
string? resolvedCategory = ResolveHookToolCategory(hook.ToolName);
|
||||
if (resolvedCategory is not null)
|
||||
{
|
||||
permissionKind = resolvedCategory;
|
||||
}
|
||||
}
|
||||
|
||||
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
|
||||
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
@@ -476,10 +504,22 @@ internal sealed class CopilotApprovalCoordinator
|
||||
PermissionRequestWrite => WritePermissionKind,
|
||||
PermissionRequestRead => ReadPermissionKind,
|
||||
PermissionRequestMemory => StoreMemoryToolName,
|
||||
PermissionRequestHook hook => ResolveHookToolCategory(hook.ToolName),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
internal static string? ResolveHookToolCategory(string? toolName)
|
||||
{
|
||||
string? normalized = NormalizeOptionalString(toolName);
|
||||
if (normalized is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return HookToolCategories.TryGetValue(normalized, out string? category) ? category : null;
|
||||
}
|
||||
|
||||
private static bool MatchesAutoApprovedTool(
|
||||
IReadOnlyList<string> autoApprovedToolNames,
|
||||
string? toolName,
|
||||
|
||||
@@ -248,11 +248,13 @@ internal static class CopilotSessionHooks
|
||||
};
|
||||
}
|
||||
|
||||
string? autoApprovedToolName = CopilotApprovalCoordinator.ResolveHookToolCategory(toolName) ?? toolName;
|
||||
|
||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||
command.Pattern.ApprovalPolicy,
|
||||
agentDefinition.Id,
|
||||
toolName,
|
||||
toolName);
|
||||
autoApprovedToolName);
|
||||
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
|
||||
@@ -151,6 +151,26 @@ public sealed class CopilotSessionHooksTests
|
||||
Assert.Equal("ask", decision?.PermissionDecision);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("view", "read")]
|
||||
[InlineData("grep", "read")]
|
||||
[InlineData("edit", "write")]
|
||||
[InlineData("powershell", "shell")]
|
||||
public async Task Create_PreToolUseAutoAllowsWhenCategoryIsApproved(string toolName, string category)
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(category);
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = toolName,
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("allow", decision?.PermissionDecision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_RunsConfiguredNonPreToolHooks()
|
||||
{
|
||||
@@ -309,6 +329,45 @@ public sealed class CopilotSessionHooksTests
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommandWithAutoApprovedCategory(string category)
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
ApprovalPolicy = new ApprovalPolicyDto
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
AutoApprovedToolNames = [category],
|
||||
},
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static HookCommandDefinition CreateHookCommand(string name)
|
||||
=> new()
|
||||
{
|
||||
|
||||
@@ -1325,6 +1325,155 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Equal("https://example.com", args["url"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("view", "read")]
|
||||
[InlineData("glob", "read")]
|
||||
[InlineData("grep", "read")]
|
||||
[InlineData("lsp", "read")]
|
||||
[InlineData("edit", "write")]
|
||||
[InlineData("create", "write")]
|
||||
[InlineData("powershell", "shell")]
|
||||
[InlineData("read_powershell", "shell")]
|
||||
[InlineData("write_powershell", "shell")]
|
||||
[InlineData("stop_powershell", "shell")]
|
||||
[InlineData("list_powershell", "shell")]
|
||||
[InlineData("web_fetch", "url")]
|
||||
[InlineData("web_search", "url")]
|
||||
[InlineData("store_memory", "memory")]
|
||||
public void ResolveHookToolCategory_ReturnsExpectedCategoryForKnownTools(string toolName, string expectedCategory)
|
||||
{
|
||||
Assert.Equal(expectedCategory, CopilotApprovalCoordinator.ResolveHookToolCategory(toolName));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("icm-mcp-get_on_call_schedule")]
|
||||
[InlineData("custom_tool")]
|
||||
[InlineData("unknown")]
|
||||
public void ResolveHookToolCategory_ReturnsNullForUnknownTools(string toolName)
|
||||
{
|
||||
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(toolName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveHookToolCategory_ReturnsNullForNullOrEmpty()
|
||||
{
|
||||
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(null));
|
||||
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(""));
|
||||
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetApprovalToolName_ResolvesHookToolToCategory()
|
||||
{
|
||||
Assert.True(
|
||||
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||
new PermissionRequestHook
|
||||
{
|
||||
Kind = "hook",
|
||||
ToolName = "view",
|
||||
ToolArgs = """{"path":"README.md"}""",
|
||||
},
|
||||
out string? toolName));
|
||||
Assert.Equal("view", toolName);
|
||||
|
||||
// But the auto-approved name (fallback) resolves to the category
|
||||
PermissionRequestHook hookRequest = new()
|
||||
{
|
||||
Kind = "hook",
|
||||
ToolName = "view",
|
||||
ToolArgs = """{"path":"README.md"}""",
|
||||
};
|
||||
|
||||
// Verify GetFallbackToolName returns category via ResolveAutoApprovedToolName path
|
||||
Assert.True(
|
||||
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||
hookRequest,
|
||||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresToolCallApproval_HonorsHookToolCategoryForAutoApproval()
|
||||
{
|
||||
ApprovalPolicyDto policy = new()
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
AutoApprovedToolNames = ["read"],
|
||||
};
|
||||
|
||||
// "view" is a hook tool that maps to "read" category — should be auto-approved
|
||||
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||
policy, "agent-1", "view", "read"));
|
||||
|
||||
// "grep" also maps to "read"
|
||||
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||
policy, "agent-1", "grep", "read"));
|
||||
|
||||
// "edit" maps to "write" — not auto-approved
|
||||
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||
policy, "agent-1", "edit", "write"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionApprovalEvent_UsesResolvedCategoryForHookPermissionKind()
|
||||
{
|
||||
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
},
|
||||
CreateAgent("agent-1", "Primary"),
|
||||
new PermissionRequestHook
|
||||
{
|
||||
Kind = "hook",
|
||||
ToolName = "view",
|
||||
ToolArgs = """{"path":"README.md"}""",
|
||||
},
|
||||
new PermissionInvocation
|
||||
{
|
||||
SessionId = "copilot-session-1",
|
||||
},
|
||||
"approval-1",
|
||||
"view");
|
||||
|
||||
Assert.Equal("view", approvalEvent.ToolName);
|
||||
Assert.Equal("read", approvalEvent.PermissionKind);
|
||||
Assert.Contains("read permission", approvalEvent.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionApprovalEvent_KeepsHookKindForUnknownHookTools()
|
||||
{
|
||||
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
},
|
||||
CreateAgent("agent-1", "Primary"),
|
||||
new PermissionRequestHook
|
||||
{
|
||||
Kind = "hook",
|
||||
ToolName = "icm-mcp-get_schedule",
|
||||
ToolArgs = """{"teamIds":[91982]}""",
|
||||
},
|
||||
new PermissionInvocation
|
||||
{
|
||||
SessionId = "copilot-session-1",
|
||||
},
|
||||
"approval-1",
|
||||
"icm-mcp-get_schedule");
|
||||
|
||||
Assert.Equal("hook", approvalEvent.PermissionKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestApprovalAsync_RaisesApprovalAndCompletesAfterResolution()
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ type AutoUpdateListener = (...args: any[]) => void;
|
||||
interface AutoUpdaterLike {
|
||||
autoDownload: boolean;
|
||||
autoInstallOnAppQuit: boolean;
|
||||
forceDevUpdateConfig: boolean;
|
||||
on(event: string, listener: AutoUpdateListener): this;
|
||||
removeListener(event: string, listener: AutoUpdateListener): this;
|
||||
checkForUpdates(): Promise<unknown>;
|
||||
@@ -151,7 +152,7 @@ export class AutoUpdateService {
|
||||
};
|
||||
|
||||
private readonly notAvailableListener = () => {
|
||||
this.publishStatus({ state: 'idle' });
|
||||
this.publishStatus({ state: 'up-to-date' });
|
||||
};
|
||||
|
||||
private readonly progressListener = (progress: AutoUpdateProgressLike) => {
|
||||
@@ -180,6 +181,7 @@ export class AutoUpdateService {
|
||||
this.scheduler = options.scheduler ?? defaultScheduler;
|
||||
this.updater.autoDownload = true;
|
||||
this.updater.autoInstallOnAppQuit = false;
|
||||
this.updater.forceDevUpdateConfig = !options.isPackaged;
|
||||
|
||||
this.updater.on('checking-for-update', this.checkingListener);
|
||||
this.updater.on('update-available', this.availableListener);
|
||||
@@ -215,10 +217,6 @@ export class AutoUpdateService {
|
||||
}
|
||||
|
||||
async checkForUpdates(): Promise<UpdateStatus> {
|
||||
if (!this.options.isPackaged) {
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
if (this.pendingCheck) {
|
||||
return this.pendingCheck;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pat
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
countApprovedToolsInGroups,
|
||||
groupApprovalToolsByProvider,
|
||||
listApprovalToolDefinitions,
|
||||
type RuntimeToolDefinition,
|
||||
@@ -162,17 +163,7 @@ export function ChatPane({
|
||||
);
|
||||
const effectiveAutoApprovedCount = useMemo(() => {
|
||||
const groups = groupApprovalToolsByProvider(approvalTools, toolingSettings);
|
||||
const counted = new Set<string>();
|
||||
for (const group of groups) {
|
||||
if (group.serverApprovalKey && effectiveAutoApproved.has(group.serverApprovalKey)) {
|
||||
for (const tool of group.tools) counted.add(tool.id);
|
||||
} else {
|
||||
for (const tool of group.tools) {
|
||||
if (effectiveAutoApproved.has(tool.id)) counted.add(tool.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return counted.size;
|
||||
return countApprovedToolsInGroups(groups, effectiveAutoApproved);
|
||||
}, [approvalTools, effectiveAutoApproved, toolingSettings]);
|
||||
const isProbingMcp = (mcpProbingServerIds?.length ?? 0) > 0;
|
||||
const hasApprovalContent = approvalTools.length > 0 || isProbingMcp;
|
||||
|
||||
@@ -51,20 +51,37 @@ const runStatusStyles: Record<SessionRunRecord['status'], { icon: ReactNode; cla
|
||||
/* ── Event node icon ───────────────────────────────────────── */
|
||||
|
||||
function EventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) {
|
||||
const base = 'size-3.5';
|
||||
const isRunning = status === 'running';
|
||||
const base = 'size-2.5';
|
||||
|
||||
// Running events use white icons to contrast with the brand-gradient circle
|
||||
if (isRunning) {
|
||||
const pulse = 'animate-pulse';
|
||||
switch (kind) {
|
||||
case 'thinking':
|
||||
return <Brain className={`${base} ${pulse} text-white`} />;
|
||||
case 'approval':
|
||||
return <AlertTriangle className={`${base} ${pulse} text-white`} />;
|
||||
case 'message':
|
||||
return <MessageSquare className={`${base} ${pulse} text-white`} />;
|
||||
default:
|
||||
return <Play className={`${base} text-white`} />;
|
||||
}
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case 'run-started':
|
||||
return <Play className={`${base} text-[var(--color-text-muted)]`} />;
|
||||
case 'thinking':
|
||||
return <Brain className={`${base} ${status === 'running' ? 'text-[var(--color-accent-sky)] animate-pulse' : 'text-[var(--color-text-muted)]'}`} />;
|
||||
return <Brain className={`${base} text-[var(--color-text-muted)]`} />;
|
||||
case 'handoff':
|
||||
return <ArrowRight className={`${base} text-[var(--color-status-warning)]`} />;
|
||||
case 'tool-call':
|
||||
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
|
||||
case 'approval':
|
||||
return <AlertTriangle className={`${base} ${status === 'running' ? 'text-[var(--color-status-warning)] animate-pulse' : status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
|
||||
return <AlertTriangle className={`${base} ${status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
|
||||
case 'message':
|
||||
return <MessageSquare className={`${base} ${status === 'running' ? 'text-[var(--color-status-info)] animate-pulse' : status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
|
||||
return <MessageSquare className={`${base} ${status === 'error' ? 'text-[var(--color-status-error)]' : 'text-[var(--color-status-success)]'}`} />;
|
||||
case 'run-completed':
|
||||
return <CheckCircle2 className={`${base} text-[var(--color-status-success)]`} />;
|
||||
case 'run-cancelled':
|
||||
@@ -95,7 +112,7 @@ function TimelineEventRow({
|
||||
<div className="relative">
|
||||
{/* Vertical connector line */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-[7px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
|
||||
<div className="absolute left-[9px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -106,7 +123,7 @@ function TimelineEventRow({
|
||||
>
|
||||
{/* Node */}
|
||||
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
|
||||
<div className={`flex size-[15px] items-center justify-center rounded-full ${event.status === 'running' ? 'brand-gradient-bg' : 'bg-[var(--color-surface-2)]'}`}>
|
||||
<div className={`flex size-[18px] items-center justify-center rounded-full ${event.status === 'running' ? 'brand-gradient-bg' : 'bg-[var(--color-surface-2)]'}`}>
|
||||
<EventIcon kind={event.kind} status={event.status} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,11 +196,11 @@ function ThinkingGroupRow({
|
||||
return (
|
||||
<div className="group relative flex w-full gap-2.5 py-1">
|
||||
{!isLast && (
|
||||
<div className="absolute left-[7px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
|
||||
<div className="absolute left-[9px] top-[22px] bottom-0 w-px bg-[var(--color-border)]" />
|
||||
)}
|
||||
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
|
||||
<div className="flex size-[15px] items-center justify-center rounded-full bg-[var(--color-surface-2)]">
|
||||
<Brain className="size-3.5 text-[var(--color-text-muted)]" />
|
||||
<div className="flex size-[18px] items-center justify-center rounded-full bg-[var(--color-surface-2)]">
|
||||
<Brain className="size-2.5 text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, Palette, Plus, RefreshCw, Server, TriangleAlert, Workflow, Wrench } from 'lucide-react';
|
||||
|
||||
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||
@@ -11,6 +11,7 @@ import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain
|
||||
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { UpdateStatus, UpdateStatusState } from '@shared/contracts/ipc';
|
||||
import {
|
||||
normalizeLspProfileDefinition,
|
||||
normalizeMcpServerDefinition,
|
||||
@@ -817,6 +818,30 @@ function TroubleshootingSection({
|
||||
}) {
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [confirmingReset, setConfirmingReset] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({ state: 'idle' });
|
||||
const [isCheckingManually, setIsCheckingManually] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.aryxApi.onUpdateStatus((status) => {
|
||||
setUpdateStatus(status);
|
||||
if (status.state !== 'checking') setIsCheckingManually(false);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
async function handleCheckForUpdates() {
|
||||
setIsCheckingManually(true);
|
||||
try {
|
||||
const status = await window.aryxApi.checkForUpdates();
|
||||
setUpdateStatus(status);
|
||||
} finally {
|
||||
setIsCheckingManually(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstallUpdate() {
|
||||
await window.aryxApi.installUpdate();
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
setIsResetting(true);
|
||||
@@ -828,64 +853,154 @@ function TroubleshootingSection({
|
||||
}
|
||||
}
|
||||
|
||||
const isChecking = isCheckingManually || updateStatus.state === 'checking';
|
||||
|
||||
function getUpdateLabel(): string {
|
||||
switch (updateStatus.state) {
|
||||
case 'checking':
|
||||
return 'Checking for updates…';
|
||||
case 'up-to-date':
|
||||
return 'Up to date';
|
||||
case 'available':
|
||||
return `Update available: v${updateStatus.version ?? 'unknown'}`;
|
||||
case 'downloading':
|
||||
return `Downloading update${updateStatus.downloadProgress ? ` (${Math.round(updateStatus.downloadProgress.percent)}%)` : '…'}`;
|
||||
case 'downloaded':
|
||||
return `Update ready: v${updateStatus.version ?? 'unknown'}`;
|
||||
case 'error':
|
||||
return 'Update check failed';
|
||||
default:
|
||||
return 'Check for updates';
|
||||
}
|
||||
}
|
||||
|
||||
function getUpdateDescription(): string {
|
||||
switch (updateStatus.state) {
|
||||
case 'checking':
|
||||
return 'Contacting the update server…';
|
||||
case 'up-to-date':
|
||||
return 'You are running the latest version of Aryx.';
|
||||
case 'available':
|
||||
case 'downloading':
|
||||
return 'A new version is being downloaded and will be installed automatically.';
|
||||
case 'downloaded':
|
||||
return 'Restart Aryx to apply the update.';
|
||||
case 'error':
|
||||
return updateStatus.error ?? 'Could not reach the update server. Try again later.';
|
||||
default:
|
||||
return 'Manually check whether a newer version of Aryx is available.';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
description="Diagnose issues and manage local application data"
|
||||
title="Troubleshooting"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<TroubleshootingAction
|
||||
description="Reveal the folder where Aryx stores workspace data, scratchpad files, and configuration."
|
||||
icon={<FolderOpen className="size-4" />}
|
||||
label="Open App Data Folder"
|
||||
onClick={onOpenAppDataFolder}
|
||||
<div className="flex min-h-full flex-col">
|
||||
<div className="flex-1">
|
||||
<SectionHeader
|
||||
description="Diagnose issues and manage local application data"
|
||||
title="Troubleshooting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-xl border border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-[var(--color-status-error)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-[13px] font-semibold text-[var(--color-status-error)]">Reset Local Workspace</h4>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
Restore Aryx to its initial state. This permanently removes all sessions, custom patterns,
|
||||
MCP server definitions, LSP profiles, and scratchpad contents. Your GitHub Copilot sign-in
|
||||
is not affected.
|
||||
</p>
|
||||
|
||||
{!confirmingReset ? (
|
||||
<button
|
||||
className="mt-3 rounded-lg border border-[var(--color-status-error)]/30 bg-[var(--color-status-error)]/10 px-3.5 py-1.5 text-[13px] font-medium text-[var(--color-status-error)] transition-all duration-200 hover:border-[var(--color-status-error)]/50 hover:bg-[var(--color-status-error)]/20"
|
||||
onClick={() => setConfirmingReset(true)}
|
||||
type="button"
|
||||
>
|
||||
Reset workspace…
|
||||
</button>
|
||||
) : (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-lg bg-[var(--color-status-error)] px-3.5 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-status-error)] disabled:opacity-50"
|
||||
disabled={isResetting}
|
||||
onClick={() => void handleReset()}
|
||||
type="button"
|
||||
>
|
||||
{isResetting ? 'Resetting…' : 'Confirm reset'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-[var(--color-border)] px-3.5 py-1.5 text-[13px] font-medium text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
disabled={isResetting}
|
||||
onClick={() => setConfirmingReset(false)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="space-y-2">
|
||||
{/* Check for updates */}
|
||||
{updateStatus.state === 'downloaded' ? (
|
||||
<button
|
||||
className="group flex w-full items-center gap-3 rounded-xl border border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/5 px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-status-success)]/40 hover:bg-[var(--color-status-success)]/10"
|
||||
onClick={() => void handleInstallUpdate()}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-[var(--color-status-success)]">
|
||||
<RefreshCw className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[13px] font-medium text-[var(--color-status-success)]">{getUpdateLabel()}</span>
|
||||
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">{getUpdateDescription()}</p>
|
||||
</div>
|
||||
)}
|
||||
<span className="rounded-lg bg-[var(--color-status-success)]/15 px-2.5 py-1 text-[11px] font-semibold text-[var(--color-status-success)]">
|
||||
Restart
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition-all duration-200 hover:border-[var(--color-border)] hover:bg-[var(--color-surface-1)] ${isChecking ? 'pointer-events-none opacity-70' : ''}`}
|
||||
disabled={isChecking}
|
||||
onClick={() => void handleCheckForUpdates()}
|
||||
type="button"
|
||||
>
|
||||
<span className={`transition-all duration-200 ${updateStatus.state === 'up-to-date' ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]'}`}>
|
||||
{updateStatus.state === 'up-to-date'
|
||||
? <CircleCheck className="size-4" />
|
||||
: <RefreshCw className={`size-4 ${isChecking ? 'animate-spin' : ''}`} />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={`text-[13px] font-medium ${updateStatus.state === 'error' ? 'text-[var(--color-status-error)]' : updateStatus.state === 'up-to-date' ? 'text-[var(--color-status-success)]' : 'text-[var(--color-text-primary)]'}`}>
|
||||
{getUpdateLabel()}
|
||||
</span>
|
||||
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">{getUpdateDescription()}</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-[var(--color-text-muted)] transition-all duration-200 group-hover:text-[var(--color-text-muted)]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<TroubleshootingAction
|
||||
description="Reveal the folder where Aryx stores workspace data, scratchpad files, and configuration."
|
||||
icon={<FolderOpen className="size-4" />}
|
||||
label="Open App Data Folder"
|
||||
onClick={onOpenAppDataFolder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-xl border border-[var(--color-status-error)]/20 bg-[var(--color-status-error)]/5 p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-[var(--color-status-error)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-[13px] font-semibold text-[var(--color-status-error)]">Reset Local Workspace</h4>
|
||||
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
Restore Aryx to its initial state. This permanently removes all sessions, custom patterns,
|
||||
MCP server definitions, LSP profiles, and scratchpad contents. Your GitHub Copilot sign-in
|
||||
is not affected.
|
||||
</p>
|
||||
|
||||
{!confirmingReset ? (
|
||||
<button
|
||||
className="mt-3 rounded-lg border border-[var(--color-status-error)]/30 bg-[var(--color-status-error)]/10 px-3.5 py-1.5 text-[13px] font-medium text-[var(--color-status-error)] transition-all duration-200 hover:border-[var(--color-status-error)]/50 hover:bg-[var(--color-status-error)]/20"
|
||||
onClick={() => setConfirmingReset(true)}
|
||||
type="button"
|
||||
>
|
||||
Reset workspace…
|
||||
</button>
|
||||
) : (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-lg bg-[var(--color-status-error)] px-3.5 py-1.5 text-[13px] font-medium text-white transition-all duration-200 hover:bg-[var(--color-status-error)] disabled:opacity-50"
|
||||
disabled={isResetting}
|
||||
onClick={() => void handleReset()}
|
||||
type="button"
|
||||
>
|
||||
{isResetting ? 'Resetting…' : 'Confirm reset'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-[var(--color-border)] px-3.5 py-1.5 text-[13px] font-medium text-[var(--color-text-secondary)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
disabled={isResetting}
|
||||
onClick={() => setConfirmingReset(false)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attribution footer */}
|
||||
<div className="mt-12 flex items-center justify-center gap-1.5 pb-2 text-[11px] text-[var(--color-text-muted)]">
|
||||
<span>Built with</span>
|
||||
<svg aria-hidden="true" className="size-3 text-[var(--color-status-error)]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
<span>by Dávid Kaya</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
|
||||
|
||||
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||
@@ -250,7 +250,27 @@ export function InlineToolsPill({
|
||||
</button>
|
||||
|
||||
{open && !disabled && (
|
||||
<div className="absolute bottom-full left-0 z-40 mb-1.5 w-64 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] py-1 shadow-2xl">
|
||||
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-[28rem] w-64 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
|
||||
{/* Header: enable / disable all */}
|
||||
<div className="sticky top-0 z-10 border-b border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||
<div className="flex items-center justify-end px-3 py-1.5">
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={() => {
|
||||
const allEnabled = enabledCount === totalCount;
|
||||
onToggle({
|
||||
enabledMcpServerIds: allEnabled ? [] : mcpServers.map((s) => s.id),
|
||||
enabledLspProfileIds: allEnabled ? [] : lspProfiles.map((p) => p.id),
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{enabledCount === totalCount ? 'Disable all' : 'Enable all'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
{workspaceMcpServers.length > 0 && (
|
||||
<McpServerGroup
|
||||
label="Workspace MCP"
|
||||
@@ -296,6 +316,7 @@ export function InlineToolsPill({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -397,9 +418,19 @@ export function InlineApprovalPill({
|
||||
.filter((g) => g.tools.length > 0 || g.label.toLowerCase().includes(searchLower));
|
||||
}, [groups, searchLower]);
|
||||
|
||||
function toggleTool(toolId: string) {
|
||||
function toggleTool(toolId: string, group: ApprovalToolGroup) {
|
||||
const next = new Set(effectiveAutoApproved);
|
||||
if (next.has(toolId)) {
|
||||
|
||||
// If the group has server-level approval, expand it to individual tools
|
||||
// so the user can selectively disable one tool.
|
||||
if (group.serverApprovalKey && next.has(group.serverApprovalKey)) {
|
||||
next.delete(group.serverApprovalKey);
|
||||
for (const tool of group.tools) {
|
||||
if (tool.id !== toolId) {
|
||||
next.add(tool.id);
|
||||
}
|
||||
}
|
||||
} else if (next.has(toolId)) {
|
||||
next.delete(toolId);
|
||||
} else {
|
||||
next.add(toolId);
|
||||
@@ -470,6 +501,31 @@ export function InlineApprovalPill({
|
||||
return expandedGroups.has(groupId);
|
||||
}
|
||||
|
||||
function isToolEffectivelyApproved(toolId: string, group: ApprovalToolGroup): boolean {
|
||||
if (effectiveAutoApproved.has(toolId)) return true;
|
||||
if (group.serverApprovalKey && effectiveAutoApproved.has(group.serverApprovalKey)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const allApprovedGlobal = effectiveAutoApprovedCount === totalItemCount && totalItemCount > 0;
|
||||
|
||||
const approveAll = useCallback(() => {
|
||||
const next = new Set<string>();
|
||||
for (const group of groups) {
|
||||
if (group.serverApprovalKey) {
|
||||
next.add(group.serverApprovalKey);
|
||||
}
|
||||
for (const tool of group.tools) {
|
||||
next.add(tool.id);
|
||||
}
|
||||
}
|
||||
onUpdate({ autoApprovedToolNames: [...next] });
|
||||
}, [groups, onUpdate]);
|
||||
|
||||
const unapproveAll = useCallback(() => {
|
||||
onUpdate({ autoApprovedToolNames: [] });
|
||||
}, [onUpdate]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
@@ -510,16 +566,25 @@ export function InlineApprovalPill({
|
||||
}`}>
|
||||
{isOverridden ? 'Session override' : 'Pattern defaults'}
|
||||
</span>
|
||||
{isOverridden && (
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{isOverridden && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={() => onUpdate({})}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="size-2.5" />
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
||||
onClick={() => onUpdate({})}
|
||||
onClick={allApprovedGlobal ? unapproveAll : approveAll}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="size-2.5" />
|
||||
Reset
|
||||
{allApprovedGlobal ? 'Unapprove all' : 'Approve all'}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
@@ -607,9 +672,9 @@ export function InlineApprovalPill({
|
||||
<div key={tool.id} className={isCollapsible ? 'pl-3' : ''}>
|
||||
<PopoverToggleRow
|
||||
detail={detail}
|
||||
enabled={effectiveAutoApproved.has(tool.id)}
|
||||
enabled={isToolEffectivelyApproved(tool.id, group)}
|
||||
label={tool.label}
|
||||
onToggle={() => toggleTool(tool.id)}
|
||||
onToggle={() => toggleTool(tool.id, group)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -642,8 +707,8 @@ function GroupToggle({
|
||||
return (
|
||||
<button
|
||||
aria-pressed={allApproved}
|
||||
className={`relative inline-flex h-[16px] w-[28px] shrink-0 items-center rounded-full transition-colors ${
|
||||
allApproved ? 'bg-[var(--color-accent)]' : someApproved ? 'bg-[var(--color-surface-3)]' : 'bg-[var(--color-surface-3)]'
|
||||
className={`relative inline-flex h-[14px] w-[24px] shrink-0 items-center rounded-full transition-all duration-200 ${
|
||||
allApproved ? 'brand-gradient-bg shadow-[0_0_8px_rgba(36,92,249,0.3)]' : 'bg-[var(--color-surface-3)]'
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
@@ -652,8 +717,8 @@ function GroupToggle({
|
||||
<Minus className="absolute left-1/2 size-2 -translate-x-1/2 text-[var(--color-text-primary)]" strokeWidth={3} />
|
||||
) : (
|
||||
<span
|
||||
className={`inline-block size-[12px] rounded-full bg-white shadow transition-transform ${
|
||||
allApproved ? 'translate-x-[13px]' : 'translate-x-[2px]'
|
||||
className={`inline-block size-[10px] rounded-full bg-white shadow-sm transition-transform ${
|
||||
allApproved ? 'translate-x-[12px]' : 'translate-x-[2px]'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -175,7 +175,7 @@ export interface SetTerminalHeightInput {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type UpdateStatusState = 'idle' | 'checking' | 'available' | 'downloading' | 'downloaded' | 'error';
|
||||
export type UpdateStatusState = 'idle' | 'checking' | 'up-to-date' | 'available' | 'downloading' | 'downloaded' | 'error';
|
||||
|
||||
export interface UpdateDownloadProgress {
|
||||
bytesPerSecond: number;
|
||||
|
||||
@@ -395,6 +395,34 @@ function resolveApprovalToolGroupKey(
|
||||
return { id: 'other', label: 'Other', kind: 'mixed' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many tools are effectively auto-approved across all groups.
|
||||
*
|
||||
* This must use per-group counting (not unique-tool-ID deduplication) to stay
|
||||
* consistent with the total-item formula used in InlineApprovalPill, which
|
||||
* counts each group occurrence independently. Without this, shared tool names
|
||||
* across MCP servers produce a numerator smaller than the denominator even when
|
||||
* everything is approved.
|
||||
*/
|
||||
export function countApprovedToolsInGroups(
|
||||
groups: ReadonlyArray<ApprovalToolGroup>,
|
||||
approved: ReadonlySet<string>,
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const group of groups) {
|
||||
if (group.serverApprovalKey && approved.has(group.serverApprovalKey)) {
|
||||
// Server-level approval covers all tools. Servers with 0 tools still
|
||||
// count as 1 (matching the totalItemCount formula).
|
||||
count += Math.max(group.tools.length, 1);
|
||||
} else {
|
||||
for (const tool of group.tools) {
|
||||
if (approved.has(tool.id)) count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
|
||||
if (!server.name.trim()) {
|
||||
return 'MCP server name is required.';
|
||||
|
||||
@@ -10,6 +10,8 @@ class FakeUpdater extends EventEmitter {
|
||||
|
||||
autoInstallOnAppQuit = true;
|
||||
|
||||
forceDevUpdateConfig = false;
|
||||
|
||||
checkForUpdatesCalls = 0;
|
||||
|
||||
quitAndInstallCalls = 0;
|
||||
@@ -62,7 +64,7 @@ class FakeScheduler implements AutoUpdateScheduler {
|
||||
}
|
||||
|
||||
describe('AutoUpdateService', () => {
|
||||
test('does not schedule checks for unpackaged apps', async () => {
|
||||
test('does not schedule checks for unpackaged apps but manual checks still work', async () => {
|
||||
const updater = new FakeUpdater();
|
||||
const scheduler = new FakeScheduler();
|
||||
const service = new AutoUpdateService({ isPackaged: false, scheduler, updater });
|
||||
@@ -71,8 +73,10 @@ describe('AutoUpdateService', () => {
|
||||
|
||||
expect(scheduler.timeouts).toHaveLength(0);
|
||||
expect(scheduler.intervals).toHaveLength(0);
|
||||
expect(await service.checkForUpdates()).toEqual({ state: 'idle' });
|
||||
expect(updater.checkForUpdatesCalls).toBe(0);
|
||||
expect(updater.forceDevUpdateConfig).toBe(true);
|
||||
|
||||
await service.checkForUpdates();
|
||||
expect(updater.checkForUpdatesCalls).toBe(1);
|
||||
});
|
||||
|
||||
test('configures auto download and schedules startup and periodic checks', async () => {
|
||||
@@ -148,6 +152,22 @@ describe('AutoUpdateService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('transitions to up-to-date when no update is available', () => {
|
||||
const updater = new FakeUpdater();
|
||||
const service = new AutoUpdateService({ isPackaged: true, updater });
|
||||
const statuses: UpdateStatus[] = [];
|
||||
service.onStatus((status) => statuses.push(status));
|
||||
|
||||
updater.emit('checking-for-update');
|
||||
updater.emit('update-not-available', {});
|
||||
|
||||
expect(statuses).toEqual([
|
||||
{ state: 'checking' },
|
||||
{ state: 'up-to-date' },
|
||||
]);
|
||||
expect(service.getStatus()).toEqual({ state: 'up-to-date' });
|
||||
});
|
||||
|
||||
test('reports updater errors and only installs once an update is downloaded', () => {
|
||||
const updater = new FakeUpdater();
|
||||
const service = new AutoUpdateService({ isPackaged: true, updater });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
buildMcpServerApprovalKey,
|
||||
countApprovedToolsInGroups,
|
||||
groupApprovalToolsByProvider,
|
||||
listApprovalToolDefinitions,
|
||||
listApprovalToolNames,
|
||||
@@ -658,3 +659,101 @@ describe('probed tools', () => {
|
||||
expect(names).toContain('mcp_server:Probed Keys');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countApprovedToolsInGroups', () => {
|
||||
const TS = '2026-03-28T00:00:00.000Z';
|
||||
|
||||
function makeTooling(
|
||||
mcpServers: McpServerDefinition[] = [],
|
||||
lspProfiles: LspProfileDefinition[] = [],
|
||||
): WorkspaceToolingSettings {
|
||||
return { mcpServers, lspProfiles };
|
||||
}
|
||||
|
||||
function makeMcpServer(id: string, name: string, tools: string[]): McpServerDefinition {
|
||||
return { id, name, transport: 'local', command: 'node', args: [], tools, createdAt: TS, updatedAt: TS };
|
||||
}
|
||||
|
||||
test('counts all tools approved when all server keys are set', () => {
|
||||
const tooling = makeTooling([
|
||||
makeMcpServer('git', 'Git MCP', ['git.status', 'git.diff']),
|
||||
makeMcpServer('fs', 'Filesystem', ['fs.read']),
|
||||
]);
|
||||
const tools = listApprovalToolDefinitions(tooling);
|
||||
const groups = groupApprovalToolsByProvider(tools, tooling);
|
||||
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
|
||||
const approved = new Set(['mcp_server:Git MCP', 'mcp_server:Filesystem']);
|
||||
|
||||
const mcpTotal = mcpGroups.reduce(
|
||||
(sum, g) => sum + Math.max(g.tools.length, g.serverApprovalKey ? 1 : 0), 0,
|
||||
);
|
||||
const count = countApprovedToolsInGroups(mcpGroups, approved);
|
||||
expect(count).toBe(mcpTotal);
|
||||
});
|
||||
|
||||
test('counts shared tool names per group, not as unique IDs', () => {
|
||||
const tooling = makeTooling([
|
||||
makeMcpServer('git-a', 'Git A', ['git.status', 'git.diff']),
|
||||
makeMcpServer('git-b', 'Git B', ['git.status', 'git.diff']),
|
||||
]);
|
||||
const tools = listApprovalToolDefinitions(tooling);
|
||||
const groups = groupApprovalToolsByProvider(tools, tooling);
|
||||
|
||||
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
|
||||
expect(mcpGroups.length).toBe(2);
|
||||
// Both servers share the same tool names — MCP total should be 4 (2 per group)
|
||||
const mcpTotal = mcpGroups.reduce(
|
||||
(sum, g) => sum + Math.max(g.tools.length, g.serverApprovalKey ? 1 : 0), 0,
|
||||
);
|
||||
expect(mcpTotal).toBe(4);
|
||||
|
||||
// Approve all via server keys — count must match total
|
||||
const approved = new Set(['mcp_server:Git A', 'mcp_server:Git B']);
|
||||
const count = countApprovedToolsInGroups(mcpGroups, approved);
|
||||
expect(count).toBe(mcpTotal);
|
||||
});
|
||||
|
||||
test('counts individual tool approvals per group when no server key', () => {
|
||||
const tooling = makeTooling([
|
||||
makeMcpServer('git-a', 'Git A', ['git.status']),
|
||||
makeMcpServer('git-b', 'Git B', ['git.status']),
|
||||
]);
|
||||
const tools = listApprovalToolDefinitions(tooling);
|
||||
const groups = groupApprovalToolsByProvider(tools, tooling);
|
||||
|
||||
// Approve only the individual tool ID (shared across both groups)
|
||||
const approved = new Set(['git.status']);
|
||||
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
|
||||
expect(mcpGroups.length).toBe(2);
|
||||
|
||||
// Each group has the same tool → count should be 2 (once per group)
|
||||
const mcpCount = countApprovedToolsInGroups(mcpGroups, approved);
|
||||
expect(mcpCount).toBe(2);
|
||||
});
|
||||
|
||||
test('counts empty server with approved key as 1', () => {
|
||||
const tooling = makeTooling([makeMcpServer('empty', 'Empty Server', [])]);
|
||||
const tools = listApprovalToolDefinitions(tooling);
|
||||
const groups = groupApprovalToolsByProvider(tools, tooling);
|
||||
const approved = new Set(['mcp_server:Empty Server']);
|
||||
|
||||
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
|
||||
expect(mcpGroups.length).toBe(1);
|
||||
expect(mcpGroups[0].tools.length).toBe(0);
|
||||
|
||||
const count = countApprovedToolsInGroups(mcpGroups, approved);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test('returns 0 when nothing is approved', () => {
|
||||
const tooling = makeTooling([
|
||||
makeMcpServer('git', 'Git MCP', ['git.status']),
|
||||
]);
|
||||
const tools = listApprovalToolDefinitions(tooling);
|
||||
const groups = groupApprovalToolsByProvider(tools, tooling);
|
||||
const approved = new Set<string>();
|
||||
|
||||
const count = countApprovedToolsInGroups(groups, approved);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user