fix(linear): show 'Cannot verify' when skill scan is inconclusive

A scan that encounters an error before discovering skills, or hits an
unreadable root, cannot vouch for "not installed". Previously these cases
were conflated with proven absence, so the checklist would claim the skill
step incomplete even when all three steps had been finished. Now the UI
distinguishes between confirmed states and unknown ones, showing "Cannot
verify" instead of listing the skill as an unfinished step.
This commit is contained in:
Jinjing
2026-09-15 22:19:53 -07:00
parent a9232e8db6
commit 38c7467248
19 changed files with 511 additions and 165 deletions
@@ -1,25 +1,30 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { LinearAgentSkillGuide } from './LinearAgentSkillGuide'
import { LinearAgentSkillGuide, type LinearSetupReadiness } from './LinearAgentSkillGuide'
const baseStatus = {
const baseReadiness: LinearSetupReadiness = {
connected: true,
connectionChecking: false,
checking: false,
skillInstalled: false,
skillChecking: false,
visibleInTasks: true
skillUnverifiable: false,
visible: true
}
function renderGuide(readiness: Partial<LinearSetupReadiness>): string {
return renderToStaticMarkup(
<LinearAgentSkillGuide
readiness={{ ...baseReadiness, ...readiness }}
onOpenTaskSources={vi.fn()}
onManageLinearAccess={vi.fn()}
skillPanel={<div data-testid="skill-panel">Skill install panel</div>}
/>
)
}
describe('LinearAgentSkillGuide', () => {
it('renders the setup checklist with an inlined skill panel', () => {
const markup = renderToStaticMarkup(
<LinearAgentSkillGuide
status={baseStatus}
onOpenTaskSources={vi.fn()}
onManageLinearAccess={vi.fn()}
skillPanel={<div data-testid="skill-panel">Skill install panel</div>}
/>
)
const markup = renderGuide({})
expect(markup).toContain('Setup checklist')
expect(markup).toContain('2 of 3 ready')
@@ -32,34 +37,11 @@ describe('LinearAgentSkillGuide', () => {
})
it('marks the checklist complete when every step is done', () => {
const markup = renderToStaticMarkup(
<LinearAgentSkillGuide
status={{
...baseStatus,
skillInstalled: true
}}
onOpenTaskSources={vi.fn()}
onManageLinearAccess={vi.fn()}
skillPanel={<div>Skill panel</div>}
/>
)
expect(markup).toContain('All set')
expect(renderGuide({ skillInstalled: true })).toContain('All set')
})
it('keeps durable progress while a skill recheck is in flight', () => {
const markup = renderToStaticMarkup(
<LinearAgentSkillGuide
status={{
...baseStatus,
skillInstalled: true,
skillChecking: true
}}
onOpenTaskSources={vi.fn()}
onManageLinearAccess={vi.fn()}
skillPanel={<div>Skill panel</div>}
/>
)
const markup = renderGuide({ skillInstalled: true, skillChecking: true })
expect(markup).toContain('Checking…')
expect(markup).not.toContain('2 of 3 ready')
@@ -67,20 +49,53 @@ describe('LinearAgentSkillGuide', () => {
})
it('keeps durable progress while a connection check is in flight', () => {
const markup = renderToStaticMarkup(
<LinearAgentSkillGuide
status={{
...baseStatus,
skillInstalled: true,
connectionChecking: true
}}
onOpenTaskSources={vi.fn()}
onManageLinearAccess={vi.fn()}
skillPanel={<div>Skill panel</div>}
/>
)
const markup = renderGuide({ skillInstalled: true, checking: true })
expect(markup).toContain('Checking…')
expect(markup).not.toContain('2 of 3 ready')
})
// The reported bug: a scan that could not vouch for "not installed" was counted
// as a step the user had left undone.
it('reports an unverifiable skill scan as unknown instead of an unfinished step', () => {
const markup = renderGuide({ skillUnverifiable: true })
expect(markup).toContain('Cannot verify')
expect(markup).toContain('2/3')
expect(markup).toContain('bg-amber-500')
expect(markup).not.toContain('2 of 3 ready')
expect(markup).not.toContain('All set')
})
it('still claims nothing while a rescan of an unverifiable step runs', () => {
const markup = renderGuide({ skillUnverifiable: true, skillChecking: true })
expect(markup).toContain('Checking…')
expect(markup).not.toContain('Cannot verify')
})
it('lets a found skill outrank a stale unverifiable flag', () => {
const markup = renderGuide({ skillInstalled: true, skillUnverifiable: true })
expect(markup).toContain('All set')
expect(markup).not.toContain('Cannot verify')
})
// The unknown-skill label is only the headline when the skill is the sole open
// question; a plainly unfinished step must still read as the count.
it('keeps the confirmed count when the unfinished step is the connection', () => {
const markup = renderGuide({ connected: false, skillUnverifiable: true })
expect(markup).toContain('1 of 3 ready')
expect(markup).not.toContain('Cannot verify')
})
it('does not headline an unknown skill over an unfinished visibility step', () => {
const markup = renderGuide({ visible: false, skillUnverifiable: true })
expect(markup).toContain('1 of 3 ready')
expect(markup).not.toContain('Cannot verify')
// Hiding Linear is deliberate, so the shared table keeps this pill neutral.
expect(markup).not.toContain('bg-amber-500')
})
})
@@ -1,19 +1,27 @@
import type { ReactNode } from 'react'
import { Check, Circle } from 'lucide-react'
import { Check, Circle, TriangleAlert } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { IntegrationStatusPill } from '@/components/integration-status-pill'
import {
IntegrationStatusPill,
type IntegrationStatusTone
} from '@/components/integration-status-pill'
import {
TASK_PROVIDER_SETUP_STATUS_TONE,
getTaskProviderCompletedSteps,
getTaskProviderSetupStatus,
type TaskProviderReadiness
} from './task-source-setup-state'
import { translate } from '@/i18n/i18n'
export type LinearSetupStepStatus = {
connected: boolean
connectionChecking: boolean
/** The guide renders the skill row, so unlike other providers those facts are required. */
export type LinearSetupReadiness = TaskProviderReadiness & {
skillInstalled: boolean
skillChecking: boolean
visibleInTasks: boolean
skillUnverifiable: boolean
}
type LinearAgentSkillGuideProps = {
status: LinearSetupStepStatus
readiness: LinearSetupReadiness
onOpenTaskSources: () => void
onManageLinearAccess: () => void
// Why: skill install/update lives once under step 2 so the page does not
@@ -23,10 +31,12 @@ type LinearAgentSkillGuideProps = {
function SetupStatusIcon({
done,
checking
checking,
unverifiable
}: {
done: boolean
checking: boolean
unverifiable?: boolean
}): React.JSX.Element {
// Keep a fixed size-5 slot so checking/done/pending never shift the column.
if (checking) {
@@ -36,6 +46,15 @@ function SetupStatusIcon({
</span>
)
}
// Why above `done`: an unvouched-for scan says nothing about the step either
// way, and painting it as pending is the claim this checklist got wrong.
if (unverifiable) {
return (
<span className="flex size-5 items-center justify-center rounded-full border border-border/70 text-muted-foreground">
<TriangleAlert className="size-3" />
</span>
)
}
if (done) {
return (
<span className="flex size-5 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
@@ -50,21 +69,64 @@ function SetupStatusIcon({
)
}
type LinearSetupPill = { tone: IntegrationStatusTone; label: string; showCount: boolean }
function getLinearSetupPill(readiness: LinearSetupReadiness): LinearSetupPill {
const { completed, total } = getTaskProviderCompletedSteps(readiness)
// Why: route through the card's status so the two Linear surfaces share one
// precedence. Reading `skillUnverifiable` directly here headlined "Cannot verify"
// over a step the user had plainly not done (or before they had even connected).
const status = getTaskProviderSetupStatus(readiness)
// Tone is the shared table's call, not this surface's; only the copy differs.
const tone = TASK_PROVIDER_SETUP_STATUS_TONE[status]
if (status === 'checking') {
return {
tone,
label: translate('auto.components.settings.LinearAgentSkillGuide.setupChecking', 'Checking…'),
showCount: false
}
}
if (status === 'ready') {
return {
tone,
label: translate('auto.components.settings.LinearAgentSkillGuide.setupReady', 'All set'),
showCount: false
}
}
// Why: a scan that cannot vouch for "not installed" must not be counted against
// the user, so the label reports what was confirmed instead of asserting a failure.
if (status === 'skill-unverified') {
return {
tone,
label: translate(
'auto.components.settings.LinearAgentSkillGuide.setupUnverified',
'Cannot verify'
),
showCount: true
}
}
return {
tone,
label: translate(
'auto.components.settings.LinearAgentSkillGuide.setupProgress',
'{{done}} of {{total}} ready',
{ done: completed, total }
),
showCount: false
}
}
// Connect, skill, and Tasks visibility in one checklist — skill UI is inlined.
export function LinearAgentSkillGuide({
status,
readiness,
onOpenTaskSources,
onManageLinearAccess,
skillPanel
}: LinearAgentSkillGuideProps): React.JSX.Element {
// Count durable outcomes even while a recheck runs so the pill does not flash
// from "All set" down to "2 of 3 ready" during skill/connection scans.
const checking = status.connectionChecking || status.skillChecking
const completed = [status.connected, status.skillInstalled, status.visibleInTasks].filter(
Boolean
).length
const total = 3
const allReady = completed === total && !checking
// Share the Task Sources card's arithmetic so the two Linear setup surfaces
// cannot disagree about the same three facts; the copy stays count-based here.
const pill = getLinearSetupPill(readiness)
const { completed, total } = getTaskProviderCompletedSteps(readiness)
return (
<section className="space-y-3 rounded-xl border border-border/60 bg-card/30 p-4">
@@ -83,23 +145,22 @@ export function LinearAgentSkillGuide({
)}
</p>
</div>
<IntegrationStatusPill tone={checking ? 'neutral' : allReady ? 'connected' : 'attention'}>
{checking
? translate('auto.components.settings.LinearAgentSkillGuide.setupChecking', 'Checking…')
: allReady
? translate('auto.components.settings.LinearAgentSkillGuide.setupReady', 'All set')
: translate(
'auto.components.settings.LinearAgentSkillGuide.setupProgress',
'{{done}} of {{total}} ready',
{ done: completed, total }
)}
</IntegrationStatusPill>
<span className="inline-flex items-center gap-2">
<IntegrationStatusPill tone={pill.tone}>{pill.label}</IntegrationStatusPill>
{pill.showCount ? (
// Mirrors the Task Sources card so the confirmed count survives a label
// that no longer carries it.
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{`${completed}/${total}`}
</span>
) : null}
</span>
</div>
<div className="divide-y divide-border/50">
<div className="flex flex-wrap items-start gap-3 py-3">
<div className="mt-0.5">
<SetupStatusIcon done={status.connected} checking={status.connectionChecking} />
<SetupStatusIcon done={readiness.connected} checking={readiness.checking} />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-foreground">
@@ -118,11 +179,11 @@ export function LinearAgentSkillGuide({
<Button
type="button"
size="sm"
variant={status.connected ? 'outline' : 'default'}
variant={readiness.connected ? 'outline' : 'default'}
className="shrink-0"
onClick={onManageLinearAccess}
>
{status.connected
{readiness.connected
? translate(
'auto.components.settings.LinearAgentSkillGuide.manageKeys',
'Manage keys'
@@ -134,7 +195,11 @@ export function LinearAgentSkillGuide({
<div className="space-y-3 py-3">
<div className="flex flex-wrap items-start gap-3">
<div className="mt-0.5">
<SetupStatusIcon done={status.skillInstalled} checking={status.skillChecking} />
<SetupStatusIcon
done={readiness.skillInstalled}
checking={readiness.skillChecking}
unverifiable={readiness.skillUnverifiable}
/>
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-foreground">
@@ -156,7 +221,7 @@ export function LinearAgentSkillGuide({
<div className="flex flex-wrap items-start gap-3 py-3">
<div className="mt-0.5">
<SetupStatusIcon done={status.visibleInTasks} checking={false} />
<SetupStatusIcon done={readiness.visible} checking={false} />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium text-foreground">
@@ -175,7 +240,7 @@ export function LinearAgentSkillGuide({
<Button
type="button"
size="sm"
variant={status.visibleInTasks ? 'outline' : 'default'}
variant={readiness.visible ? 'outline' : 'default'}
className="shrink-0"
onClick={onOpenTaskSources}
>
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
panelProps: [] as Record<string, unknown>[],
runtime: 'native' as 'native' | 'wsl',
skillInstalled: true,
skillUnverifiable: false,
updateSkillName: 'orca-linear',
linearConnected: true,
visibleTaskProviders: ['github', 'linear'] as string[],
@@ -58,8 +59,11 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({
useInstalledAgentSkillNames: () => ({
installed: mocks.skillInstalled,
loading: false,
settled: true,
installedUnverifiable: mocks.skillUnverifiable,
error: null,
skills: [],
sources: [],
refresh: vi.fn()
})
}))
@@ -135,6 +139,7 @@ describe('LinearAgentSkillPane', () => {
mocks.panelProps.length = 0
mocks.runtime = 'native'
mocks.skillInstalled = true
mocks.skillUnverifiable = false
mocks.updateSkillName = 'orca-linear'
mocks.linearConnected = true
mocks.visibleTaskProviders = ['github', 'linear']
@@ -210,6 +215,15 @@ describe('LinearAgentSkillPane', () => {
}
})
it('reports an unverifiable skill scan as unknown instead of an unfinished step', () => {
mocks.skillInstalled = false
mocks.skillUnverifiable = true
const markup = renderToStaticMarkup(<LinearAgentSkillPane />)
expect(markup).toContain('Cannot verify')
expect(markup).not.toContain('2 of 3 ready')
})
it('shows incomplete checklist when the skill is missing', () => {
mocks.skillInstalled = false
const markup = renderToStaticMarkup(<LinearAgentSkillPane />)
@@ -107,12 +107,13 @@ export function LinearAgentSkillPane(): React.JSX.Element {
className="space-y-6 py-2"
>
<LinearAgentSkillGuide
status={{
readiness={{
connected: linearConnected,
connectionChecking,
checking: connectionChecking,
skillInstalled: skillSetup.skillInstalled,
skillChecking: skillSetup.skillChecking,
visibleInTasks
skillUnverifiable: skillSetup.skillUnverifiable,
visible: visibleInTasks
}}
onOpenTaskSources={openTaskSources}
onManageLinearAccess={
@@ -129,4 +129,23 @@ describe('TaskSourceProviderCard', () => {
expect(markup).toContain('>Shown</button>')
expect(markup).not.toContain('>Hide</button>')
})
it('labels an unverifiable skill scan as unknown while keeping the confirmed count', () => {
const markup = renderToStaticMarkup(
<TaskSourceProviderCard
icon={<span />}
name="Linear"
description="Linear setup"
readiness={{ ...readiness, connected: true, skillUnverifiable: true }}
visible
canHide
defaultExpanded={false}
onToggleVisible={vi.fn()}
/>
)
expect(markup).toContain('Cannot verify')
expect(markup).toContain('2/3')
expect(markup).not.toContain('Skill required')
})
})
@@ -45,6 +45,11 @@ function getSetupStatusLabel(status: TaskProviderSetupStatus): string {
'auto.components.settings.TaskSourceProviderCard.statusSkillRequired',
'Skill required'
)
case 'skill-unverified':
return translate(
'auto.components.settings.TaskSourceProviderCard.statusUnverified',
'Cannot verify'
)
case 'unavailable':
return translate(
'auto.components.settings.TaskSourceProviderCard.statusUnavailable',
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { TaskProvider } from '../../../../shared/task-providers'
import {
TASK_PROVIDER_SETUP_STATUS_TONE,
getAutoExpandedTaskProvider,
getIncompleteVisibleTaskProviders,
getStalledVisibleTaskProviders,
@@ -84,6 +85,46 @@ describe('task-source-setup-state', () => {
expect(isTaskProviderReady({ connected: true, checking: true, visible: true })).toBe(false)
})
// A skill scan that could not vouch for "not installed" is not a step the user
// left undone, so it must not read as `skill-required`.
it('reports an unverifiable skill scan as unknown rather than as a missing step', () => {
const unverifiable = {
connected: true,
checking: false,
skillInstalled: false,
skillChecking: false,
skillUnverifiable: true,
visible: true
}
expect(getTaskProviderSetupStatus(unverifiable)).toBe('skill-unverified')
expect(TASK_PROVIDER_SETUP_STATUS_TONE['skill-unverified']).toBe('attention')
expect(isTaskProviderReady(unverifiable)).toBe(false)
// The count reports confirmed steps, so it is unchanged by the unknown.
expect(getTaskProviderCompletedSteps(unverifiable)).toEqual({ completed: 2, total: 3 })
})
it('keeps an in-flight check and an unconnected provider ahead of an unverifiable scan', () => {
expect(
getTaskProviderSetupStatus({
connected: true,
checking: true,
skillInstalled: false,
skillUnverifiable: true,
visible: true
})
).toBe('checking')
expect(
getTaskProviderSetupStatus({
connected: false,
checking: false,
skillInstalled: false,
skillUnverifiable: true,
visible: true
})
).toBe('connect-required')
})
it('reports the first unmet step as the status', () => {
expect(getTaskProviderSetupStatus({ connected: false, checking: true, visible: true })).toBe(
'checking'
@@ -8,6 +8,8 @@ export type TaskProviderReadiness = {
/** Linear only — agent skill install. Other providers leave this undefined. */
skillInstalled?: boolean
skillChecking?: boolean
/** The scan could not vouch for `skillInstalled: false`: an unread root, or an error before any answer. */
skillUnverifiable?: boolean
visible: boolean
}
@@ -16,6 +18,7 @@ export type TaskProviderSetupStatus =
| 'ready'
| 'connect-required'
| 'skill-required'
| 'skill-unverified'
| 'unavailable'
| 'hidden'
| 'incomplete'
@@ -30,6 +33,7 @@ export const TASK_PROVIDER_SETUP_STATUS_TONE: Record<
hidden: 'neutral',
'connect-required': 'attention',
'skill-required': 'attention',
'skill-unverified': 'attention',
unavailable: 'attention',
incomplete: 'attention'
}
@@ -83,6 +87,10 @@ export function getTaskProviderSetupStatus(
if (!readiness.connected) {
return 'connect-required'
}
// Why before `skill-required`: that status offers Install, which reinstalls a skill that may be present.
if (readiness.skillUnverifiable) {
return 'skill-unverified'
}
if (readiness.skillInstalled === false) {
return 'skill-required'
}
@@ -30,6 +30,8 @@ export function useLinearAgentSkillSetup(): {
// Status surfaces (step badges, checklist pills) read this so a focus-triggered
// rescan does not flip a known result back to "checking".
skillChecking: boolean
/** The scan could not vouch for "not installed", so no surface may claim it. */
skillUnverifiable: boolean
installDisabled: boolean
error: string | null
terminalShellOverride: string | undefined
@@ -44,6 +46,7 @@ export function useLinearAgentSkillSetup(): {
installed: skillInstalled,
loading: skillLoading,
settled: skillSettled,
installedUnverifiable: skillUnverifiable,
error: skillError,
skills: linearSkills,
refresh: refreshSkill
@@ -98,6 +101,7 @@ export function useLinearAgentSkillSetup(): {
skillInstalled,
skillLoading,
skillChecking: skillLoading && !skillSettled,
skillUnverifiable,
installDisabled,
error: activeSkillRuntime.installDisabledReason ?? skillError,
terminalShellOverride: activeSkillRuntime.terminalShellOverride,
@@ -13,8 +13,10 @@ const mocks = vi.hoisted(() => ({
installed: false,
loading: false,
settled: true,
installedUnverifiable: false,
error: null,
skills: [],
sources: [],
refresh: vi.fn()
}
}))
@@ -91,8 +93,10 @@ beforeEach(() => {
installed: true,
loading: false,
settled: true,
installedUnverifiable: false,
error: null,
skills: [],
sources: [],
refresh: vi.fn()
}
})
@@ -169,4 +173,12 @@ describe('useTaskSourceProviderReadiness', () => {
await renderProbe(['github', 'linear', 'jira'])
expect(latest?.jira.visible).toBe(true)
})
it('carries an unverifiable skill scan through to Linear readiness', async () => {
mocks.skill = { ...mocks.skill, installed: false, installedUnverifiable: true }
await renderProbe()
expect(latest?.linear.skillInstalled).toBe(false)
expect(latest?.linear.skillUnverifiable).toBe(true)
})
})
@@ -36,7 +36,8 @@ export function useTaskSourceProviderReadiness(
const {
installed: linearSkillInstalled,
loading: linearSkillLoading,
settled: linearSkillSettled
settled: linearSkillSettled,
installedUnverifiable: linearSkillUnverifiable
} = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, {
discoveryTarget: activeSkillRuntime.discoveryTarget,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
@@ -83,6 +84,7 @@ export function useTaskSourceProviderReadiness(
checking: linearChecking,
skillInstalled: linearSkillInstalled,
skillChecking: linearSkillLoading && !linearSkillSettled,
skillUnverifiable: linearSkillUnverifiable,
visible: visible.has('linear')
},
jira: {
@@ -101,6 +103,7 @@ export function useTaskSourceProviderReadiness(
linearSkillInstalled,
linearSkillLoading,
linearSkillSettled,
linearSkillUnverifiable,
reviewChecking,
reviewUnavailable,
visibleProvidersKey
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import type { SkillDiscoverySource, SkillSourceKind } from '../../../shared/skills'
import { GLOBAL_AGENT_SKILL_SOURCE_KINDS } from './useInstalledAgentSkills'
import {
getInstalledAgentSkillVerdict,
hasUnreadableAgentSkillSource,
type InstalledAgentSkillScan
} from './installed-agent-skill-verdict'
function source(
sourceKind: SkillSourceKind,
skippedReason?: SkillDiscoverySource['skippedReason']
): SkillDiscoverySource {
return {
id: `${sourceKind}-root`,
label: sourceKind,
path: `/roots/${sourceKind}`,
sourceKind,
providers: ['claude'],
owner: null,
// An unread root reports `exists`: the host could not prove otherwise.
exists: true,
...(skippedReason ? { skippedReason } : {})
}
}
function scan(overrides: Partial<InstalledAgentSkillScan> = {}): InstalledAgentSkillScan {
return {
enabled: true,
installed: false,
settled: true,
error: null,
sources: [],
sourceKinds: ['home'],
...overrides
}
}
const unverifiable = (overrides: Partial<InstalledAgentSkillScan>): boolean =>
getInstalledAgentSkillVerdict(scan(overrides)).installedUnverifiable
const error = (overrides: Partial<InstalledAgentSkillScan>): string | null =>
getInstalledAgentSkillVerdict(scan(overrides)).error
describe('hasUnreadableAgentSkillSource', () => {
it('flags a root that did not answer even though it reports as present', () => {
expect(hasUnreadableAgentSkillSource([source('home', 'unavailable')])).toBe(true)
})
it('ignores roots that were scanned or are genuinely absent', () => {
expect(
hasUnreadableAgentSkillSource([
source('home'),
{ ...source('home'), id: 'gone', exists: false, skippedReason: 'missing' }
])
).toBe(false)
})
it('ignores an unread root outside the scopes the caller asked about', () => {
expect(
hasUnreadableAgentSkillSource(
[source('repo', 'unavailable')],
GLOBAL_AGENT_SKILL_SOURCE_KINDS
)
).toBe(false)
})
})
describe('getInstalledAgentSkillVerdict', () => {
it('treats a complete scan that found nothing as proof of absence', () => {
expect(unverifiable({ sources: [source('home')] })).toBe(false)
})
it('cannot vouch for a negative when a root this query cares about did not answer', () => {
expect(unverifiable({ sources: [source('home', 'unavailable')] })).toBe(true)
})
it('ignores an unreadable root outside the queried source kinds', () => {
expect(unverifiable({ sources: [source('repo', 'unavailable')] })).toBe(false)
})
// The reported bug: `sources` is empty until a result lands, so a scan that
// errored before answering is invisible to the unreadable-root check.
it('cannot vouch for a negative when the scan errored before ever answering', () => {
expect(unverifiable({ settled: false, error: 'scan failed' })).toBe(true)
})
it('keeps an answer it already holds when a later refresh fails', () => {
expect(unverifiable({ settled: true, error: 'scan failed' })).toBe(false)
})
it('stays silent while a first scan is still pending with no error', () => {
expect(unverifiable({ settled: false })).toBe(false)
})
it('takes finding the skill as proof, whatever else failed', () => {
expect(unverifiable({ installed: true, settled: false, error: 'scan failed' })).toBe(false)
})
it('says nothing about a query that is switched off', () => {
expect(unverifiable({ enabled: false, sources: [source('home', 'unavailable')] })).toBe(false)
})
it("prefers the scan's own failure over the advisory", () => {
expect(error({ settled: false, error: 'scan failed' })).toBe('scan failed')
})
it('advises when an unreadable root is the only reason the answer is empty', () => {
expect(error({ sources: [source('home', 'unavailable')] })).toContain('did not respond')
})
it('stays quiet for a trustworthy negative', () => {
expect(error({ sources: [source('home')] })).toBeNull()
})
})
@@ -0,0 +1,65 @@
import type { SkillDiscoverySource, SkillSourceKind } from '../../../shared/skills'
import { translate } from '@/i18n/i18n'
/**
* True when a root this query cares about did not answer, so its skills are
* unknown rather than absent. The host serves such a root's last answer, but a
* root that has never answered has none to serve, and a bare "Not installed"
* there offers Install for a skill that may already be present.
*/
export function hasUnreadableAgentSkillSource(
sources: readonly SkillDiscoverySource[],
sourceKinds?: readonly SkillSourceKind[]
): boolean {
return sources.some(
(source) =>
source.skippedReason === 'unavailable' &&
(!sourceKinds || sourceKinds.includes(source.sourceKind))
)
}
export type InstalledAgentSkillScan = {
enabled: boolean
installed: boolean
/** A scan answered for this target; a cached answer counts. */
settled: boolean
/** The scan's own failure, before the advisory below is folded in. */
error: string | null
sources: readonly SkillDiscoverySource[]
sourceKinds?: readonly SkillSourceKind[]
}
export type InstalledAgentSkillVerdict = {
/** Nothing proves the skill absent, so no surface may render it as undone. */
installedUnverifiable: boolean
/** The scan's own failure, else the advisory an unverifiable negative earns. */
error: string | null
}
/**
* Finding the skill is proof, so only a negative is ever doubted. Two shapes
* qualify: a scan that answered without reading a root this query cares about,
* and a scan that never answered at all — invisible to `sources`, which stay
* empty until a result lands. A failed refresh over an answer already held is
* neither: that answer still stands.
*/
export function getInstalledAgentSkillVerdict(
scan: InstalledAgentSkillScan
): InstalledAgentSkillVerdict {
const installedUnverifiable =
scan.enabled &&
!scan.installed &&
(hasUnreadableAgentSkillSource(scan.sources, scan.sourceKinds) ||
(!scan.settled && scan.error !== null))
return {
installedUnverifiable,
error:
scan.error ??
(installedUnverifiable
? translate(
'auto.hooks.useInstalledAgentSkills.unreadableSkillSource',
'A skill folder did not respond, so this status may be incomplete.'
)
: null)
}
}
@@ -735,6 +735,8 @@ describe('useInstalledAgentSkill', () => {
// fresh discovery per store write for as long as the host stays unreachable.
expect(discover).toHaveBeenCalledTimes(1)
expect(latestState?.error).toBe('runtime host unreachable')
// No result ever landed, so "not installed" is a claim this scan cannot back.
expect(latestState?.installedUnverifiable).toBe(true)
})
it('hydrates from the warm cache on its very first render pass', async () => {
@@ -882,6 +884,34 @@ describe('useInstalledAgentSkill', () => {
expect(latestState?.installed).toBe(false)
})
it('keeps a landed answer authoritative when a later refresh fails', async () => {
const discover = vi
.fn<(target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>>()
.mockResolvedValueOnce(discoveryResult([]))
.mockRejectedValue(new Error('refresh failed'))
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills: { discover } }
})
await renderProbe()
await flushMicrotasks()
expect(discover).toHaveBeenCalledTimes(1)
expect(latestState?.settled).toBe(true)
expect(latestState?.installedUnverifiable).toBe(false)
await act(async () => {
notifyInstalledAgentSkillsChanged()
})
await flushMicrotasks()
// The refresh failed, but the answer the scan already landed still stands.
expect(discover).toHaveBeenCalledTimes(2)
expect(latestState?.error).toBe('refresh failed')
expect(latestState?.settled).toBe(true)
expect(latestState?.installedUnverifiable).toBe(false)
})
it('empties the discovery cache when an install notification fires', async () => {
// Why: assert the cache directly — a mounted component forces a rescan and
// would hide a missing invalidation.
@@ -1,16 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
DiscoveredSkill,
SkillDiscoveryResult,
SkillDiscoverySource
} from '../../../shared/skills'
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../shared/skills'
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
import {
GLOBAL_AGENT_SKILL_SOURCE_KINDS,
_installedAgentSkillDiscoveryInternalsForTests,
hasInstalledAgentSkill,
hasInstalledAgentSkillNamed,
hasUnreadableAgentSkillSource,
notifyInstalledAgentSkillsRefreshed
} from './useInstalledAgentSkills'
@@ -162,44 +157,6 @@ describe('hasInstalledAgentSkill', () => {
})
})
describe('hasUnreadableAgentSkillSource', () => {
function source(overrides: Partial<SkillDiscoverySource>): SkillDiscoverySource {
return {
id: 'home',
label: 'Agent skills home',
path: '/Users/test/.agents/skills',
sourceKind: 'home',
providers: ['agent-skills'],
owner: null,
// An unread root reports `exists`: the host could not prove otherwise.
exists: true,
...overrides
}
}
it('flags a root that did not answer even though it reports as present', () => {
expect(hasUnreadableAgentSkillSource([source({ skippedReason: 'unavailable' })])).toBe(true)
})
it('ignores roots that were scanned or are genuinely absent', () => {
expect(
hasUnreadableAgentSkillSource([
source({}),
source({ id: 'gone', exists: false, skippedReason: 'missing' })
])
).toBe(false)
})
it('ignores an unread root outside the scopes the caller asked about', () => {
expect(
hasUnreadableAgentSkillSource(
[source({ id: 'repo', sourceKind: 'repo', skippedReason: 'unavailable' })],
GLOBAL_AGENT_SKILL_SOURCE_KINDS
)
).toBe(false)
})
})
describe('isOrchestrationSkillName', () => {
it('matches only the orchestration skill name', () => {
expect(
@@ -8,7 +8,6 @@ import type {
} from '../../../shared/skills'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import { markOrchestrationSetupComplete } from '@/lib/orchestration-setup-state'
import { translate } from '@/i18n/i18n'
import {
discoverInstalledAgentSkills,
getCachedSkillDiscovery,
@@ -16,6 +15,10 @@ import {
getSkillDiscoveryTargetKey,
resetSkillDiscoveryCacheForTests
} from './installed-agent-skill-discovery'
import {
getInstalledAgentSkillVerdict,
type InstalledAgentSkillScan
} from './installed-agent-skill-verdict'
import {
INSTALLED_AGENT_SKILLS_CHANGED_EVENT,
INSTALLED_AGENT_SKILLS_REFRESHED_EVENT
@@ -48,6 +51,8 @@ export type InstalledAgentSkillState = {
// Why: a forced rescan keeps the previous result, so only the first scan per
// runtime-scoped target is genuinely unknown.
settled: boolean
// A negative this scan cannot vouch for: render it as unknown, not as undone.
installedUnverifiable: boolean
error: string | null
skills: readonly DiscoveredSkill[]
sources: readonly SkillDiscoverySource[]
@@ -94,23 +99,6 @@ export function hasInstalledAgentSkillNamed(
})
}
/**
* True when a root this query cares about did not answer, so its skills are
* unknown rather than absent. The host serves such a root's last answer, but a
* root that has never answered has none to serve, and a bare "Not installed"
* there offers Install for a skill that may already be present.
*/
export function hasUnreadableAgentSkillSource(
sources: readonly SkillDiscoverySource[],
sourceKinds?: readonly SkillSourceKind[]
): boolean {
return sources.some(
(source) =>
source.skippedReason === 'unavailable' &&
(!sourceKinds || sourceKinds.includes(source.sourceKind))
)
}
export function notifyInstalledAgentSkillsRefreshed(): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(INSTALLED_AGENT_SKILLS_REFRESHED_EVENT))
@@ -323,10 +311,15 @@ export function useInstalledAgentSkillNames(
[candidateSkillNames, enabled, skills, sourceKinds]
)
const incompleteScan = useMemo(
() => enabled && !installed && hasUnreadableAgentSkillSource(sources, sourceKinds),
[enabled, installed, sources, sourceKinds]
)
const settled = enabled && resultForRender !== null
const scan: InstalledAgentSkillScan = {
enabled,
installed,
settled,
error: errorForRender,
sources,
sourceKinds
}
useEffect(() => {
if (installed && candidateSkillNames.some(isOrchestrationSkillName)) {
@@ -341,15 +334,8 @@ export function useInstalledAgentSkillNames(
return {
installed,
loading: loadingForRender,
settled: enabled && resultForRender !== null,
error:
errorForRender ??
(incompleteScan
? translate(
'auto.hooks.useInstalledAgentSkills.unreadableSkillSource',
'A skill folder did not respond, so this status may be incomplete.'
)
: null),
settled,
...getInstalledAgentSkillVerdict(scan),
skills,
sources,
refresh: forceRefresh
+4 -2
View File
@@ -11519,7 +11519,8 @@
"noteKeysBody": "API keys and workspaces are stored for the active runtime.",
"noteVisibilityTitle": "Hiding ≠ disconnect",
"noteVisibilityBody": "Hiding Linear in Task Sources only removes it from the picker. It does not remove your key or skill.",
"setupChecking": "Checking…"
"setupChecking": "Checking…",
"setupUnverified": "Cannot verify"
},
"TaskSourceLinearSetup": {
"connectTitle": "Connect Linear",
@@ -11545,7 +11546,8 @@
"statusHidden": "Hidden from Tasks",
"statusIncomplete": "Needs setup",
"collapseSetup": "Collapse {{provider}} setup steps",
"expandSetup": "Show {{provider}} setup steps"
"expandSetup": "Show {{provider}} setup steps",
"statusUnverified": "Cannot verify"
},
"TaskSourceShowInTasksStep": {
"shown": "Shown",
+3 -1
View File
@@ -10269,7 +10269,8 @@
"noteKeysBody": "API 密钥和工作区存储在当前运行环境中。",
"noteVisibilityTitle": "隐藏不等于断开连接",
"noteVisibilityBody": "在任务来源中隐藏 Linear 只会将其从选择器中移除,不会删除密钥或技能。",
"setupChecking": "检查中…"
"setupChecking": "检查中…",
"setupUnverified": "无法验证"
},
"TaskSourceLinearSetup": {
"connectTitle": "连接 Linear",
@@ -10291,6 +10292,7 @@
"statusReady": "已就绪",
"statusConnectRequired": "需要连接",
"statusSkillRequired": "需要技能",
"statusUnverified": "无法验证",
"statusUnavailable": "状态不可用",
"statusHidden": "已从任务中隐藏",
"statusIncomplete": "需要设置",
@@ -52,6 +52,9 @@ const REQUIRED_KEYS: Record<string, string> = {
'auto.components.settings.ComputerUsePane.statusGranted': 'Granted',
'auto.components.settings.ComputerUsePane.statusUnsupported': 'macOS only',
'auto.components.settings.ComputerUsePane.statusNotEnabled': 'Not enabled',
// Linear setup checklist — unverifiable skill scan (Settings pane + Task Sources card)
'auto.components.settings.LinearAgentSkillGuide.setupUnverified': 'Cannot verify',
'auto.components.settings.TaskSourceProviderCard.statusUnverified': 'Cannot verify',
// Source-control CLI integration cards
'auto.components.settings.cli.source.control.integration.cards.statusConnected': 'Connected',
'auto.components.settings.cli.source.control.integration.cards.statusUnavailable': 'Unavailable',