Files
hermes-agent/apps/desktop/electron/plugin-compat-notice.test.ts
T
Teknium 0a5164cebe compat(plugins): tell users which installed plugins break on 2026-09-14, and stop loading them after
hermes_cli/plugin_compat.py is now the single source of truth for the compat window:
  COMPAT_REMOVAL_DATE = 2026-09-14; scan_plugin() statically finds `from F import n`, `import F` + `F.n`,
  alias forms and string targets against compat_manifest.json; compat_report() aggregates over the user's
  ENABLED external (non-bundled) plugins; disable_reason() decides the loader's skip.

Surfaces (all read from that one report):
  * CLI: yellow block under the banner naming plugins + date + `hermes plugins compat` (red + DISABLED after)
  * `hermes plugins compat [--json] [path]`: file:line, old -> new per hit; exit 1 while anything remains;
    `path` lets a plugin author scan their own checkout
  * `hermes doctor`: "Plugin import paths (removed Sep 14, 2026)" section next to the xAI retirement check
  * `hermes update`: post-update notice alongside the FTS/curator notices
  * Desktop: compat_report() writes HERMES_HOME/.plugin-compat-report.json (deleted when clean); Electron
    shows ONE warning dialog per distinct report after the backend is up and persists the dismissal in
    userData/plugin-compat-dismissed.json. A new affected plugin, or the date passing, is a new report.

From the date, PluginManager skips a hitting external plugin before importing it, with the reason in
LoadedPlugin.error ("uses N import path(s) removed on 2026-09-14; run `hermes plugins compat` ...") — the
same path a plugin with a broken register() takes, so nothing else is affected. Escape hatch:
plugins.allow_deprecated_imports: true (config_defaults), which only helps until the compat commit is
actually reverted.

Docs: COMPAT_MANIFEST.md (removal date, what-happens table, author instructions), plugin dev guide section.
Tests: tests/test_plugin_compat_notice.py (scanner forms, report scope, date gate + escape hatch, summary
text, report file lifecycle, loader skip via a real PluginManager), electron/plugin-compat-notice.test.ts
(show once, re-show on a different set or on the date passing, malformed file ignored).

Live A/B on this box with a demo plugin on old paths: before the date it loads and the banner/doctor/report
name it; with today=2026-09-14 it is skipped with the reason and the banner turns red; with the escape
hatch it loads again.
2026-09-04 01:28:31 -07:00

75 lines
3.2 KiB
TypeScript

import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import { DISMISSED_FILE, REPORT_FILE, pendingNotice, recordDismissed, reportKey } from './plugin-compat-notice'
function tmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-compat-'))
}
const REPORT = {
removal_date: '2026-09-14',
in_effect: false,
plugins: {
alpha: [{ file: '__init__.py', line: 3, old: 'tools.web_tools.prefers_gateway', new: 'tools.tool_backend_helpers.prefers_gateway' }],
beta: [
{ file: 'a.py', line: 1, old: 'hermes_cli.kanban_db.connect', new: 'hermes_cli.kanban_db_connect.connect' },
{ file: 'a.py', line: 9, old: 'hermes_cli.kanban_db.connect_closing', new: 'hermes_cli.kanban_db_connect.connect_closing' }
]
},
lines: ['2 plugins use import paths that stop working on 2026-09-14 (10 days): alpha (1), beta (2)', 'Details: hermes plugins compat']
}
test('no report file → no notice', () => {
assert.equal(pendingNotice(tmp(), tmp()), null)
})
test('report → one notice naming plugins, date and the CLI command', () => {
const home = tmp()
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
const n = pendingNotice(home, tmp())
assert.ok(n)
assert.equal(n.title, 'Plugins need an update')
assert.match(n.message, /2 plugins import module paths that stop working on 2026-09-14/)
assert.match(n.detail, /• alpha — 1 import \(e\.g\. tools\.web_tools\.prefers_gateway → tools\.tool_backend_helpers\.prefers_gateway\)/)
assert.match(n.detail, /• beta — 2 imports/)
assert.match(n.detail, /hermes plugins compat/)
})
test('dismissal is remembered for the same report and forgotten for a different one', () => {
const home = tmp()
const userData = tmp()
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
const first = pendingNotice(home, userData)
assert.ok(first)
recordDismissed(userData, first.key)
assert.ok(fs.existsSync(path.join(userData, DISMISSED_FILE)))
assert.equal(pendingNotice(home, userData), null, 'same report must not show twice')
// a third affected plugin is new information
const grown = { ...REPORT, plugins: { ...REPORT.plugins, gamma: [{ file: 'g.py', line: 1, old: 'x.y', new: 'z.y' }] } }
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(grown))
const second = pendingNotice(home, userData)
assert.ok(second)
assert.notEqual(second.key, first.key)
// the date passing (plugins now disabled) is new information too, with different wording
const disabled = { ...REPORT, in_effect: true }
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(disabled))
const third = pendingNotice(home, userData)
assert.ok(third)
assert.equal(third.title, 'Some plugins were not loaded')
assert.match(third.detail, /allow_deprecated_imports/)
assert.notEqual(reportKey(disabled as any), reportKey(REPORT as any))
})
test('empty or malformed report is ignored', () => {
const home = tmp()
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify({ ...REPORT, plugins: {} }))
assert.equal(pendingNotice(home, tmp()), null)
fs.writeFileSync(path.join(home, REPORT_FILE), '{not json')
assert.equal(pendingNotice(home, tmp()), null)
})