dsh-waifu-agents 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MAINTAINER.md +17 -3
- package/client.js +54 -6
- package/index.js +221 -6
- package/package.json +5 -5
- package/selftest.mjs +202 -0
package/MAINTAINER.md
CHANGED
|
@@ -66,7 +66,20 @@ updateCheck updateApply updateProgress purge
|
|
|
66
66
|
- **原子写**:`writeAtomic()` = 临时文件 + `rename`,覆盖前留 `<name>.bak`。
|
|
67
67
|
- **缓存**:`(mtimeMs, size)` 键;`chars` 是「去掉 front matter 并 trim 之后的 JS 字符串长度」,和字节数没有稳定换算。
|
|
68
68
|
- **`list` 不带绝对路径**:它给 `id / idOk / name / order / description / group / template / chars / charsFull / problems / owned / mtimeMs / size`。
|
|
69
|
-
- **`roster` 多带一个 `defaultResolves`(三态)**:`null` = 没有任何面报出默认值,**不知道**;否则 = `presets.some(r => r.id === defaultId)
|
|
69
|
+
- **`roster` 多带一个 `defaultResolves`(三态)**:`null` = 没有任何面报出默认值,**不知道**;否则 = `presets.some(r => r.id === defaultId)`。`null` 不能压成 `false`:那会冤枉一个单纯什么都没设的 profile。客户端的 `defaultResolves` state 同样是三态,设置页只在 `=== false` 时报警。
|
|
70
|
+
⚠️ **这条旗标曾经把后果写反过,别照着旧注释改回去。** 旧文案(注释和 `defaultUnresolved` 都是)说「默认指不到人 → 新会话悄悄落到列表第一项」,依据是 composer 的 `fallback = presets.find(p => p.isDefault)?.id ?? presets[0]?.id`(`ui-agent-preset.js:1263`)。那是**渲染层**的兜底,永远轮不到它跑:服务层先解析,`retain(id)` 直接抛
|
|
71
|
+
```js
|
|
72
|
+
// registry.js:494 —— 默认就是 selectedDefault ?? config.default
|
|
73
|
+
get defaultId() { return this.config.selectedDefault.get() ?? this.config.default; }
|
|
74
|
+
// registry.js:643(resolve / readDocument 同样)
|
|
75
|
+
throw new RemoteError("agent-preset/not-found", `Unknown agent preset: ${wanted}`, { agentPreset: wanted, available: [...] });
|
|
76
|
+
```
|
|
77
|
+
所以真实后果是**每一个新会话都被拒绝**(用户看到的那条 `新建会话失败:agent-preset/not-found: Unknown agent preset: 照桥心美`),不是静默回落到第一行。写这条断言时别用「回落」这个词——它会把一个 `P0` 级故障描述成显示问题。
|
|
78
|
+
- **悬空的默认是自愈的,不是只报告。** `reconcileDefault()` 在三个地方跑:`status()`(op `list`,也就是**打开面板就修**)、`remove()`(退掉的人设正好是默认那位)、以及启动时 `scan()` 之后的 `.then()`。判定是「`selectedDefault` 有值,但 `agentPresets.list()` 里没有这个 id」;动作只有**清掉**(`moveDefaultOff`),**绝不替用户改指到另一个妻子**——清空会回落到 profile 的 `default`,替用户选人则是另一件更响的事。两个必须保留的细节:
|
|
79
|
+
- **名册为空时不动**(`rows.length === 0` → 「名册还是空的,先不动」)。别的插件在**它自己的** `apply` 里注册 preset,本行完全可能先被激活;此时读名册会把自己还没注册的 id 判成悬空。这正是启动那一跳要 `.then()` 挂在 `scan()` **之后**的原因——先让本插件自己的人设进名册。空名册守卫是同一件事的兜底。
|
|
80
|
+
- **`moveDefaultOff` 是两步,和 `setDefault` 一样**:`writeDefault`/`verifyDefault` 走 `settings` 服务并**重读确认**,然后 `resettleUnstarted` 把还挂在那个死 id 上、没开口的会话挪走。只清字段的话,屏上那个空会话的 header 还钉着死 id,chip 照样是坏的,建会话照样失败。
|
|
81
|
+
- 修复失败**必须出声**:`status()` 回传 `defaultHeal: { ok: false, from, reason }`,面板用 `defaultHealFailed` 显示(此时新会话仍然起不来,是唯一不能静默的一条)。成功回传 `{ ok: true, from, nowDefault, moved }`。`remove()` 回传 `defaultFollowed: 'cleared' | 'unchanged' | 'failed'`。
|
|
82
|
+
- **`remove()` 现在是第二个会改默认的写路径**(第一个是 `rename()` 的 `repointDefault`)。退掉的正好是默认那位时,不清默认就留下一个悬空 id,等于「删人设 = 下次新会话失败」。爆炸半径同样压住:退的不是默认那位时**一个字节都不写**(断言 `defaultFollowed === 'unchanged'`)。
|
|
70
83
|
- **非法 id 不进选择器**:`choosableRows()` 只收 `problems` 为空且 `idOk` 为真的行。
|
|
71
84
|
- **条件样式必须把键写进基础样式,不能让 React 去"移除"。** 这一条看着像代码风格,其实是面板高亮的生死线。React 丢弃一个不再收到的样式属性时写的是 `style[name] = ''`;对普通属性这没问题(开关的 `transform` 就是这么被清掉的,实测正常),但对**简写里的属性不行**:清掉 `borderColor` 之后,`border: 1px solid …` 会把颜色重新应用一遍,而"颜色被清掉"的边框会回落到 `currentColor`。本主题下 `currentColor`(`--dsw-alias-label-primary`)和品牌色(`--dsw-alias-brand-primary` = `neutral-bluish-50`)是同一个近白,于是**一个已经不是默认的行看起来仍然选中**,只有重挂载才会好。这正是用户报的「多次切换默认人设后,选中过的 border 样式存在残留,再次进入就消失了」——而且只发生在**点过的**那些行上(点过才有机会被设过品牌色),没点过的行用的是显式的 `border-l1`。所以 `L.item` 里同时写明 `border` 和 `borderColor`/`borderStyle`,`L.switch` 写明 `borderColor`,`L.thumb` 写明 `transform: 'translateX(0)'`。自测里有一条按**键集合**遍历所有 `条件变体/基础` 配对的断言,加新的条件样式时它会替你拦住。
|
|
72
85
|
- **会话顶部那个 chip 不是我们的,是 `projectionValues.agentPreset`。** 它由**唯一一种事件**推进:`agent-preset/selected`(`registry.js` 的 `apply` 只认这个),而写它的只有平台的 `select()`。`select()` 对**已开过轮**的会话会抛 `agent-preset/locked` —— 因为"真的换"意味着重挂载整套 preset、连工具一起换。所以 `/waifu-change` 追加**事件本身**:只动标签,不动正在跑的树;人设本来就有 override 在供,标签和模型听到的因此终于一致。两条硬约束:① 只能写**已注册**的 preset id(值会被持久化,恢复会话时按它挂载,写一个没人提供的 id 会让下次恢复失败);② 插件配置的 `syncSessionChip: false` 可以整条关掉(关的理由是这条通道同时决定恢复时挂载谁)。`/waifu-reset` 对应地把标签挪回"会话挂载的那一位,没有就回落到全局默认"。
|
|
@@ -89,7 +102,7 @@ updateCheck updateApply updateProgress purge
|
|
|
89
102
|
## 测试与 CI
|
|
90
103
|
|
|
91
104
|
```bash
|
|
92
|
-
pnpm test # = node selftest.mjs,
|
|
105
|
+
pnpm test # = node selftest.mjs,313 条断言,离线、不碰 profile、不联网
|
|
93
106
|
pnpm pack # 先看产物清单
|
|
94
107
|
pnpm publish --no-git-checks
|
|
95
108
|
```
|
|
@@ -125,7 +138,8 @@ CI 在 `.github/workflows/test.yml`:push 到 `main`、开 PR、或手动触发
|
|
|
125
138
|
18. `/waifu-reset` → 回落到这个会话原本挂载的那一篇。
|
|
126
139
|
19. **开一个新任务(别发消息)→ 看顶部 preset 下拉**:四个内置模式(standard / ptc / minimal / cordis)必须在**最上面四行**,自定义人设全在下面。这是 `PERSONA_ORDER_BASE` 唯一的验收点。
|
|
127
140
|
20. **默认开关连点**:把默认从 A 换到 B,再换到 C,**每换一次都留在面板里看**——高亮边框必须**当次**就落到新行上,不能还挂在旧行上等下次进来才自愈。然后开新任务,顶部下拉默认选中的必须是 C。
|
|
128
|
-
21. 把默认指向一个**正在被编辑、暂时组装不出来**的人设 →
|
|
141
|
+
21. 把默认指向一个**正在被编辑、暂时组装不出来**的人设 → 设置页「新会话默认用谁」下面必须出现「这个默认指不到任何可用的人设」。
|
|
129
142
|
22. **改默认不该需要重启**:在插件面板把默认从 A 换成 B → **立刻**开一个新任务(别发消息)→ 顶部选择器默认选中的必须是 B。这是「写入有没有通知平台」唯一的验收点(`settings/document-updated`);走 `configEditor.edit()` 的旧路径这里会显示 A。
|
|
130
143
|
23. **切完看会话顶上那个标签**:在同一个会话里 `/waifu-change` 到另一位 → 会话标题旁边的标签必须**跟着变**。它不动就是 `agent-preset/selected` 没追加成功;把它写成 `syncSessionChip: false` 时标签**应该**保持不动。
|
|
131
144
|
24. **改默认要连"当前这个空会话"一起换**:让新任务页停在屏幕上别动 → 在插件面板把默认从 A 换成 B → **不要新建会话**,回来看新任务页顶上的选择器,必须已经变成 B。这一条和第 22 条**不是同一件事**:第 22 条考的是"之后新建的会话拿谁"(`selectedDefault`),这条考的是"已经摆在屏幕上的那个空会话"(`projectionValues.agentPreset`)。只修了第 22 条的版本,这里会停在 A——而且 Agent 预设页的「新任务默认」徽章**已经是 B**,两处对不上就是它。
|
|
145
|
+
25. **删掉人设文件 / 退掉默认那位,然后重启** —— 这是「悬空默认」的现场复现,也是这一版修复的验收点:① 把默认指向 A,**关掉 DSH**,把 `~/.dsh/personas/A.md` 从磁盘删掉(不走面板),重开 DSH → 新任务必须能正常建起来,**不能**出现 `agent-preset/not-found`;② 日志里应有一条 `WARN dsh-waifu-agents: 默认人设「A」已经不在名册里了…已清掉这条默认`;③ 打开插件面板 → 「新会话默认用谁」那行下面出现一条「已自动清掉」的说明(`defaultHealed*`),刷新一次面板后它消失;④ 面板里把默认设成 B → 在**面板里**退掉 B(归档)→ 默认必须跟着清掉,`~/.dsh/personas` 里出现 `B.md.off`,且下一次新任务不再失败。只有清了字段、没把屏上那个空会话一起挪走的版本,会在 ① 那里照样失败。
|
package/client.js
CHANGED
|
@@ -174,7 +174,11 @@ window.__ModuleLoader__.load({
|
|
|
174
174
|
defaultUnchecked: '写进去了,但这个 profile 读不回来确认(既没有 settings,名册里也没有 isDefault)。这一行没报错,可也没人能担保平台认它。',
|
|
175
175
|
defaultReseated: '也把当前这个还没开口的会话换过去了——新任务页顶上现在就是她。',
|
|
176
176
|
defaultViaEditor: '警告:这次没走 settings 服务,退回配置编辑器写的。磁盘上是对的,但平台收不到通知,新任务页可能不跟——看日志里那条 warn。',
|
|
177
|
-
defaultUnresolved: '
|
|
177
|
+
defaultUnresolved: '这个默认指不到任何可用的人设——新建会话会直接失败(agent-preset/not-found),不是悄悄回落。插件会把它清掉,让新会话回落到 profile 默认。',
|
|
178
|
+
defaultHealed: '默认「{from}」已经不在名册里了——留着它新建会话会被 agent-preset/not-found 直接挡掉。已自动清掉,新会话现在用「{to}」。',
|
|
179
|
+
defaultHealedNobody: '默认「{from}」已经不在名册里了——留着它新建会话会被 agent-preset/not-found 直接挡掉。已自动清掉,新会话回落到 profile 默认。',
|
|
180
|
+
defaultHealedMoved: ' 还没开口的会话也一起换过去了。',
|
|
181
|
+
defaultHealFailed: '默认「{from}」已经不在名册里了,新建会话会被 agent-preset/not-found 直接挡掉——但这次没能自动清掉:{reason}。去「设置 → Agent Presets」重新选一个。',
|
|
178
182
|
diagnostics: '诊断',
|
|
179
183
|
defaultReadFrom: '默认值读自',
|
|
180
184
|
srcRemoteExportList: '会话预置清单',
|
|
@@ -338,7 +342,11 @@ window.__ModuleLoader__.load({
|
|
|
338
342
|
defaultUnchecked: 'The write went through, but this profile cannot read the field back to confirm it (no `settings`, and the roster carries no `isDefault`). Nothing errored — and nothing can vouch that the platform agrees.',
|
|
339
343
|
defaultReseated: 'The session already open on the new-task screen was moved too — its chip names her now.',
|
|
340
344
|
defaultViaEditor: 'Warning: this went through the config editor instead of the settings service. The disk is right, but the platform was not told, so the new-task screen may not follow — see the warning in the log.',
|
|
341
|
-
defaultUnresolved: 'This default points at no usable persona, so a new session
|
|
345
|
+
defaultUnresolved: 'This default points at no usable persona, so a new session fails outright with agent-preset/not-found — it does NOT fall back to the first row. The plugin clears it, and new sessions go back to the profile default.',
|
|
346
|
+
defaultHealed: 'The default "{from}" is no longer in the roster, so new sessions would be refused with agent-preset/not-found. It has been cleared; new sessions now use "{to}".',
|
|
347
|
+
defaultHealedNobody: 'The default "{from}" is no longer in the roster, so new sessions would be refused with agent-preset/not-found. It has been cleared, and new sessions fall back to the profile default.',
|
|
348
|
+
defaultHealedMoved: ' The sessions that had not opened a turn were moved with it.',
|
|
349
|
+
defaultHealFailed: 'The default "{from}" is no longer in the roster, so new sessions are refused with agent-preset/not-found — and clearing it did not work: {reason}. Pick another one under Settings → Agent Presets.',
|
|
342
350
|
diagnostics: 'Diagnostics',
|
|
343
351
|
defaultReadFrom: 'default read from',
|
|
344
352
|
srcRemoteExportList: 'session preset list',
|
|
@@ -1489,6 +1497,32 @@ window.__ModuleLoader__.load({
|
|
|
1489
1497
|
return h('span', { style: L.meta }, key === undefined ? String(source ?? '—') : t(key));
|
|
1490
1498
|
}
|
|
1491
1499
|
|
|
1500
|
+
/**
|
|
1501
|
+
* The one-line report for a default the Host had to repair.
|
|
1502
|
+
*
|
|
1503
|
+
* A default that names a preset nothing registers is not cosmetic: the
|
|
1504
|
+
* registry refuses EVERY new session with `agent-preset/not-found`
|
|
1505
|
+
* (`registry.js:643`), so this has to be said even though the user did not ask
|
|
1506
|
+
* — and it has to distinguish 「修好了」 from 「没修掉」, because only the second
|
|
1507
|
+
* one leaves new sessions still refusing to open.
|
|
1508
|
+
* @param {{ ok: boolean, from?: string, nowDefault?: string | null, moved?: string[], reason?: string } | null | undefined} heal
|
|
1509
|
+
* @param {(key: string) => string} t
|
|
1510
|
+
*/
|
|
1511
|
+
function healNote(heal, t) {
|
|
1512
|
+
if (heal === null || heal === undefined) return null;
|
|
1513
|
+
const from = heal.from ?? '';
|
|
1514
|
+
if (heal.ok !== true)
|
|
1515
|
+
return t('defaultHealFailed')
|
|
1516
|
+
.replace('{from}', from)
|
|
1517
|
+
.replace('{reason}', heal.reason ?? '');
|
|
1518
|
+
const to = typeof heal.nowDefault === 'string' ? heal.nowDefault : '';
|
|
1519
|
+
const base =
|
|
1520
|
+
to === ''
|
|
1521
|
+
? t('defaultHealedNobody').replace('{from}', from)
|
|
1522
|
+
: t('defaultHealed').replace('{from}', from).replace('{to}', to);
|
|
1523
|
+
return (heal.moved ?? []).length > 0 ? base + t('defaultHealedMoved') : base;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1492
1526
|
function SettingsTab({
|
|
1493
1527
|
data,
|
|
1494
1528
|
t,
|
|
@@ -1598,13 +1632,27 @@ window.__ModuleLoader__.load({
|
|
|
1598
1632
|
h('span', { style: L.meta }, data?.defaultId ?? '—'),
|
|
1599
1633
|
h(Btn, { variant: 'toolbar', disabled: busy || data?.defaultId == null, onClick: onClearDefault }, t('clearDefault')),
|
|
1600
1634
|
),
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1635
|
+
/*
|
|
1636
|
+
* A default that resolves to no live row is the failure the platform never
|
|
1637
|
+
* reports: the service throws `agent-preset/not-found` at session creation
|
|
1638
|
+
* (`registry.js:643`) — the composer's 「落到第一项」 fallback is never
|
|
1639
|
+
* reached — so every new task is refused with nothing on screen to explain
|
|
1640
|
+
* it. The Host clears it on open; these two lines are the report.
|
|
1641
|
+
*/
|
|
1605
1642
|
defaultResolves === false
|
|
1606
1643
|
? h('div', { style: L.card }, h('span', { style: L.error }, t('defaultUnresolved')))
|
|
1607
1644
|
: null,
|
|
1645
|
+
data?.defaultHeal != null
|
|
1646
|
+
? h(
|
|
1647
|
+
'div',
|
|
1648
|
+
{ style: L.card },
|
|
1649
|
+
h(
|
|
1650
|
+
'span',
|
|
1651
|
+
{ style: data.defaultHeal.ok === true ? L.ok : L.error },
|
|
1652
|
+
healNote(data.defaultHeal, t),
|
|
1653
|
+
),
|
|
1654
|
+
)
|
|
1655
|
+
: null,
|
|
1608
1656
|
h(
|
|
1609
1657
|
SettingRow,
|
|
1610
1658
|
{ title: t('paging'), hint: t('pagingHint') },
|
package/index.js
CHANGED
|
@@ -1940,8 +1940,14 @@ export function apply(ctx, config = {}) {
|
|
|
1940
1940
|
* Panel state. `defaultId` comes from the LIVE roster rather than from the
|
|
1941
1941
|
* config file: `selectedDefault` can be stale, or point at a preset that no
|
|
1942
1942
|
* longer resolves, and the roster is what actually decides the next session.
|
|
1943
|
+
*
|
|
1944
|
+
* Opening the panel is also a repair point — see `reconcileDefault`, which runs
|
|
1945
|
+
* first here. A default that names nothing does not merely read wrong: the
|
|
1946
|
+
* registry refuses every new session with `agent-preset/not-found`, so leaving
|
|
1947
|
+
* the panel to only REPORT it would mean reporting a broken app.
|
|
1943
1948
|
*/
|
|
1944
1949
|
async function status() {
|
|
1950
|
+
const healed = await reconcileDefault();
|
|
1945
1951
|
// Live, not straight from the cache: `selectedDefault` can be stale or point
|
|
1946
1952
|
// at a preset that no longer resolves, and the roster is what actually decides
|
|
1947
1953
|
// the next session. Reporting the cached id is how the default's active border
|
|
@@ -1959,6 +1965,24 @@ export function apply(ctx, config = {}) {
|
|
|
1959
1965
|
defaultId: cachedDefault.id,
|
|
1960
1966
|
defaultSource: cachedDefault.source,
|
|
1961
1967
|
defaultError: cachedDefault.error,
|
|
1968
|
+
/*
|
|
1969
|
+
* What the repair did, in the one case where the user has to know: the
|
|
1970
|
+
* default they set is gone. `null` when there was nothing to repair.
|
|
1971
|
+
* `ok: false` is the degrade path and it has to be visible — a default that
|
|
1972
|
+
* names nothing still blocks every new session, so a silent failure here
|
|
1973
|
+
* would look exactly like the bug it is supposed to fix.
|
|
1974
|
+
*/
|
|
1975
|
+
defaultHeal:
|
|
1976
|
+
healed.healed !== null
|
|
1977
|
+
? {
|
|
1978
|
+
ok: true,
|
|
1979
|
+
from: healed.healed,
|
|
1980
|
+
nowDefault: healed.nowDefault ?? null,
|
|
1981
|
+
moved: healed.moved ?? [],
|
|
1982
|
+
}
|
|
1983
|
+
: healed.from === undefined
|
|
1984
|
+
? null
|
|
1985
|
+
: { ok: false, from: healed.from, reason: healed.reason },
|
|
1962
1986
|
skeleton: {
|
|
1963
1987
|
present: skeleton !== null,
|
|
1964
1988
|
file: skeleton === null ? path.join(dir, SKELETON_FILE) : skeleton.file,
|
|
@@ -2333,7 +2357,26 @@ export function apply(ctx, config = {}) {
|
|
|
2333
2357
|
const archived = nextArchiveName(id);
|
|
2334
2358
|
const wasRegistered = await unregister(id);
|
|
2335
2359
|
fs.renameSync(full, archived);
|
|
2336
|
-
|
|
2360
|
+
/*
|
|
2361
|
+
* Retiring the wife the default names used to leave `selectedDefault` pointing
|
|
2362
|
+
* at nothing, and the registry then refuses EVERY new session with
|
|
2363
|
+
* `agent-preset/not-found` (`registry.js:643`) — so the one operation that
|
|
2364
|
+
* deliberately removes a persona was also the one that could break the next
|
|
2365
|
+
* new task, with the archived file sitting right there as if all were well.
|
|
2366
|
+
* `rename()` has followed its id for a while (`repointDefault`); removing had
|
|
2367
|
+
* no such step.
|
|
2368
|
+
*
|
|
2369
|
+
* Best effort by design: the file is already archived and that must not be
|
|
2370
|
+
* undone because the config write failed, so the outcome is reported instead.
|
|
2371
|
+
*/
|
|
2372
|
+
const dropped = await moveDefaultOff(id);
|
|
2373
|
+
const follow =
|
|
2374
|
+
dropped.ok === true
|
|
2375
|
+
? { defaultFollowed: "cleared", defaultCleared: true }
|
|
2376
|
+
: dropped.skipped === true
|
|
2377
|
+
? { defaultFollowed: "unchanged" }
|
|
2378
|
+
: { defaultFollowed: "failed", defaultError: dropped.reason };
|
|
2379
|
+
return { ok: true, id, file: full, archived, wasRegistered, ...follow };
|
|
2337
2380
|
}
|
|
2338
2381
|
|
|
2339
2382
|
/**
|
|
@@ -2977,6 +3020,155 @@ export function apply(ctx, config = {}) {
|
|
|
2977
3020
|
}
|
|
2978
3021
|
}
|
|
2979
3022
|
|
|
3023
|
+
/**
|
|
3024
|
+
* Move the default off one id, and take the unstarted sessions with it.
|
|
3025
|
+
*
|
|
3026
|
+
* The counterpart of `setDefault` for the case where the id being named can no
|
|
3027
|
+
* longer be used. The registry resolves the default at session creation —
|
|
3028
|
+
* `get defaultId() { return this.config.selectedDefault.get() ?? this.config.default }`
|
|
3029
|
+
* (`registry.js:494`) — and `retain()` throws
|
|
3030
|
+
*
|
|
3031
|
+
* agent-preset/not-found: Unknown agent preset: <id>
|
|
3032
|
+
*
|
|
3033
|
+
* (`registry.js:643`, same in `resolve()` and `readDocument()`) when nothing
|
|
3034
|
+
* holds it. So a dangling default is not a display problem that sorts itself
|
|
3035
|
+
* out: it blocks EVERY new session, and the refusal comes from the service, so
|
|
3036
|
+
* the composer's own `presets.find(p => p.isDefault)?.id ?? presets[0]?.id`
|
|
3037
|
+
* fallback (`ui-agent-preset.js:1263`) never gets a chance to run.
|
|
3038
|
+
*
|
|
3039
|
+
* Same two steps as `setDefault`, for the same reasons: write through the
|
|
3040
|
+
* settings service so the app re-reads the roster, confirm it by REREADING
|
|
3041
|
+
* rather than by believing the write, and re-seat the blank sessions that were
|
|
3042
|
+
* still following the id being dropped.
|
|
3043
|
+
*
|
|
3044
|
+
* @param {string} from the id the default is expected to hold right now
|
|
3045
|
+
* @param {{ before?: object }} [context] a `refreshDefault({force:true})` the
|
|
3046
|
+
* caller already did, so this does not have to repeat the read
|
|
3047
|
+
* @returns {Promise<{ ok: boolean, reason: string | null, skipped?: boolean,
|
|
3048
|
+
* nowDefault?: string | null, via?: string, moved?: string[], refused?: number }>}
|
|
3049
|
+
*/
|
|
3050
|
+
async function moveDefaultOff(from, context = {}) {
|
|
3051
|
+
const wanted = oneLine(from);
|
|
3052
|
+
if (wanted === "")
|
|
3053
|
+
return { ok: false, skipped: true, reason: "没有指定默认" };
|
|
3054
|
+
const before = context.before ?? (await refreshDefault({ force: true }));
|
|
3055
|
+
if (oneLine(before.id) !== wanted)
|
|
3056
|
+
return { ok: false, skipped: true, reason: "默认已经不是它了" };
|
|
3057
|
+
const configEditor = ctx.get("configEditor");
|
|
3058
|
+
if (configEditor === undefined || configEditor === null)
|
|
3059
|
+
return {
|
|
3060
|
+
ok: false,
|
|
3061
|
+
reason: "这个环境没有 configEditor 服务,改不了配置。",
|
|
3062
|
+
};
|
|
3063
|
+
let entry;
|
|
3064
|
+
try {
|
|
3065
|
+
entry = findRegistryEntry(configEditor);
|
|
3066
|
+
} catch (error) {
|
|
3067
|
+
return { ok: false, reason: `读不到配置项:${error.message ?? error}` };
|
|
3068
|
+
}
|
|
3069
|
+
if (entry === undefined)
|
|
3070
|
+
return { ok: false, reason: `找不到 ${REGISTRY_PACKAGE} 这条配置项` };
|
|
3071
|
+
const written = await writeDefault({ configEditor, entry, id: null });
|
|
3072
|
+
if (!written.ok) return { ok: false, reason: `清不掉:${written.error}` };
|
|
3073
|
+
const settled = await verifyDefault(null, {
|
|
3074
|
+
previous: before.field,
|
|
3075
|
+
checkable: before.field !== undefined,
|
|
3076
|
+
});
|
|
3077
|
+
if (!settled.verified && settled.checkable)
|
|
3078
|
+
return {
|
|
3079
|
+
ok: false,
|
|
3080
|
+
reason: `写进去了,但读回来还是「${settled.id ?? "(没指定)"}」`,
|
|
3081
|
+
};
|
|
3082
|
+
/*
|
|
3083
|
+
* The field decides what a session created LATER gets; this decides what the
|
|
3084
|
+
* session already sitting on the new-task screen gets. Without it the blank
|
|
3085
|
+
* session keeps the dead id its header was stamped with — a chip naming a
|
|
3086
|
+
* wife that no longer exists, and a creation path that fails for the same
|
|
3087
|
+
* reason the default did.
|
|
3088
|
+
*/
|
|
3089
|
+
const resettled = await resettleUnstarted({
|
|
3090
|
+
to: oneLine(cachedDefault.id),
|
|
3091
|
+
from: wanted,
|
|
3092
|
+
});
|
|
3093
|
+
return {
|
|
3094
|
+
ok: true,
|
|
3095
|
+
reason: null,
|
|
3096
|
+
nowDefault: cachedDefault.id,
|
|
3097
|
+
via: written.via,
|
|
3098
|
+
moved: resettled.moved,
|
|
3099
|
+
refused: resettled.refused,
|
|
3100
|
+
};
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
/**
|
|
3104
|
+
* Clear a `selectedDefault` that names a preset nothing registers.
|
|
3105
|
+
*
|
|
3106
|
+
* Run from `status()` (so opening the panel heals), from the boot scan, and
|
|
3107
|
+
* from `remove()`. Each of those is a moment where the default can be seen to
|
|
3108
|
+
* be dead, and the failure it causes is the worst this plugin can produce:
|
|
3109
|
+
* every new session refused with `agent-preset/not-found`, with nothing in the
|
|
3110
|
+
* UI to explain it and nothing to click to get out.
|
|
3111
|
+
*
|
|
3112
|
+
* It only ever clears — never re-points at another wife. Choosing a persona on
|
|
3113
|
+
* the user's behalf would be a different, louder decision than undoing a broken
|
|
3114
|
+
* one, and the profile default is what the field falls back to.
|
|
3115
|
+
*
|
|
3116
|
+
* @returns {Promise<{ healed: string | null, from?: string, reason: string | null,
|
|
3117
|
+
* nowDefault?: string | null, moved?: string[], refused?: number, via?: string }>}
|
|
3118
|
+
*/
|
|
3119
|
+
async function reconcileDefault() {
|
|
3120
|
+
const before = await refreshDefault({ force: true });
|
|
3121
|
+
const picked = oneLine(before.id);
|
|
3122
|
+
if (picked === "")
|
|
3123
|
+
return { healed: null, reason: "没指定默认" };
|
|
3124
|
+
let rows;
|
|
3125
|
+
try {
|
|
3126
|
+
rows = await ctx.agentPresets.list();
|
|
3127
|
+
} catch (error) {
|
|
3128
|
+
return { healed: null, reason: `读不到名册:${error?.message ?? error}` };
|
|
3129
|
+
}
|
|
3130
|
+
if (!Array.isArray(rows)) return { healed: null, reason: "名册没回数组" };
|
|
3131
|
+
/*
|
|
3132
|
+
* An empty roster is 「还没人注册」, not 「谁都指不到」. A plugin that registers
|
|
3133
|
+
* its presets after this row was applied would otherwise have its default
|
|
3134
|
+
* cleared out from under it for no reason — and the cost of waiting is nil,
|
|
3135
|
+
* because `status()` runs this again every time the panel opens.
|
|
3136
|
+
*/
|
|
3137
|
+
if (rows.length === 0)
|
|
3138
|
+
return { healed: null, reason: "名册还是空的,先不动" };
|
|
3139
|
+
if (rows.some((row) => oneLine(row?.id) === picked))
|
|
3140
|
+
return { healed: null, reason: "默认指的这个人设还在" };
|
|
3141
|
+
const dropped = await moveDefaultOff(picked, { before });
|
|
3142
|
+
if (!dropped.ok) {
|
|
3143
|
+
// Say it out loud: a default that names nothing blocks every new session,
|
|
3144
|
+
// and failing to repair it silently is exactly how this looked before —
|
|
3145
|
+
// like nothing was wrong until the next new task refused to open.
|
|
3146
|
+
ctx.logger?.warn?.(
|
|
3147
|
+
`dsh-waifu-agents: 默认人设「${picked}」已经不在名册里了(新会话会被 ` +
|
|
3148
|
+
`agent-preset/not-found 直接挡掉),可是清不掉它:${dropped.reason}`,
|
|
3149
|
+
);
|
|
3150
|
+
return { healed: null, from: picked, reason: dropped.reason };
|
|
3151
|
+
}
|
|
3152
|
+
const now = oneLine(dropped.nowDefault);
|
|
3153
|
+
ctx.logger?.warn?.(
|
|
3154
|
+
`dsh-waifu-agents: 默认人设「${picked}」已经不在名册里了(新会话会被 ` +
|
|
3155
|
+
`agent-preset/not-found 直接挡掉),已清掉这条默认,新会话回落到` +
|
|
3156
|
+
`${now === "" ? " profile 默认" : `「${now}」`}` +
|
|
3157
|
+
(dropped.moved.length > 0
|
|
3158
|
+
? `,顺手把 ${dropped.moved.length} 个还没开口的会话也挪过去了。`
|
|
3159
|
+
: "。"),
|
|
3160
|
+
);
|
|
3161
|
+
return {
|
|
3162
|
+
healed: picked,
|
|
3163
|
+
from: picked,
|
|
3164
|
+
nowDefault: dropped.nowDefault,
|
|
3165
|
+
moved: dropped.moved,
|
|
3166
|
+
refused: dropped.refused,
|
|
3167
|
+
via: dropped.via,
|
|
3168
|
+
reason: null,
|
|
3169
|
+
};
|
|
3170
|
+
}
|
|
3171
|
+
|
|
2980
3172
|
/** Point the registry's `selectedDefault` at one wife, or at nobody. */
|
|
2981
3173
|
async function setDefault(request) {
|
|
2982
3174
|
const id = String(request.id ?? "").trim();
|
|
@@ -3485,11 +3677,17 @@ export function apply(ctx, config = {}) {
|
|
|
3485
3677
|
defaultSource: cachedDefault.source,
|
|
3486
3678
|
defaultError: cachedDefault.error,
|
|
3487
3679
|
// Does the persisted default name a preset this registry actually holds?
|
|
3488
|
-
//
|
|
3489
|
-
//
|
|
3490
|
-
//
|
|
3491
|
-
//
|
|
3492
|
-
//
|
|
3680
|
+
//
|
|
3681
|
+
// This used to be written down as 「悄悄落到列表第一项」, quoting the
|
|
3682
|
+
// composer's own `fallback = presets.find(p => p.isDefault)?.id ??
|
|
3683
|
+
// presets[0]?.id ?? ""` (`ui-agent-preset.js:1263`). That is the
|
|
3684
|
+
// RENDERER's fallback and it is never reached: the service resolves the
|
|
3685
|
+
// default first, and `retain()` throws
|
|
3686
|
+
// `agent-preset/not-found: Unknown agent preset: <id>` (`registry.js:643`).
|
|
3687
|
+
// So a dangling default does not degrade to the first row — it blocks
|
|
3688
|
+
// EVERY new session outright, with the panel nowhere in the picture and no
|
|
3689
|
+
// row to click. The flag stays; what it means is 「新会话会直接失败」, and
|
|
3690
|
+
// `reconcileDefault()` now clears it instead of only reporting it.
|
|
3493
3691
|
//
|
|
3494
3692
|
// `null` when no face named a default at all: that means 「不知道」, not
|
|
3495
3693
|
// 「指不到人」. Reporting `false` there would accuse a profile that simply
|
|
@@ -4145,5 +4343,22 @@ export function apply(ctx, config = {}) {
|
|
|
4145
4343
|
ctx.logger?.warn?.(
|
|
4146
4344
|
`dsh-waifu-agents: 首次扫描失败:${error?.message ?? error}`,
|
|
4147
4345
|
);
|
|
4346
|
+
})
|
|
4347
|
+
/*
|
|
4348
|
+
* Then the default. `scan()` only ADOPTS files — it never looks at what
|
|
4349
|
+
* `selectedDefault` names — so a persona deleted off disk while the app was
|
|
4350
|
+
* closed leaves the registry pointing at a preset nothing holds, and every
|
|
4351
|
+
* new session is then refused with `agent-preset/not-found`
|
|
4352
|
+
* (`registry.js:643`) with nothing on screen to explain why.
|
|
4353
|
+
*
|
|
4354
|
+
* Chained AFTER the scan rather than run beside it, so the roster already
|
|
4355
|
+
* carries this plugin's own rows: otherwise a `selectedDefault` naming one of
|
|
4356
|
+
* OUR personas would look dangling and be cleared for nothing.
|
|
4357
|
+
*/
|
|
4358
|
+
.then(() => reconcileDefault())
|
|
4359
|
+
.catch((error) => {
|
|
4360
|
+
ctx.logger?.warn?.(
|
|
4361
|
+
`dsh-waifu-agents: 默认人设自检失败:${error?.message ?? error}`,
|
|
4362
|
+
);
|
|
4148
4363
|
});
|
|
4149
4364
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-waifu-agents",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Registers every *.md in a persona directory as an agent preset, and manages them from the Plugins settings page.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
"persona",
|
|
12
12
|
"agent-preset"
|
|
13
13
|
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node selftest.mjs"
|
|
16
|
+
},
|
|
14
17
|
"exports": {
|
|
15
18
|
".": "./index.js",
|
|
16
19
|
"./client": "./client.js",
|
|
@@ -44,8 +47,5 @@
|
|
|
44
47
|
"@deepseek-ai/dsh-client-ui-settings-plugins"
|
|
45
48
|
]
|
|
46
49
|
}
|
|
47
|
-
},
|
|
48
|
-
"scripts": {
|
|
49
|
-
"test": "node selftest.mjs"
|
|
50
50
|
}
|
|
51
|
-
}
|
|
51
|
+
}
|
package/selftest.mjs
CHANGED
|
@@ -869,6 +869,193 @@ check(
|
|
|
869
869
|
services = {};
|
|
870
870
|
defaultId = null;
|
|
871
871
|
|
|
872
|
+
console.log('--- a default that names a persona nothing registers ---');
|
|
873
|
+
/*
|
|
874
|
+
* `selectedDefault` is resolved at session creation, and the SERVICE is the one
|
|
875
|
+
* that refuses:
|
|
876
|
+
*
|
|
877
|
+
* get defaultId() { return this.config.selectedDefault.get() ?? this.config.default }
|
|
878
|
+
* // registry.js:494, feeding retain(id)
|
|
879
|
+
* throw new RemoteError("agent-preset/not-found", `Unknown agent preset: ${wanted}`, …)
|
|
880
|
+
* // registry.js:643
|
|
881
|
+
*
|
|
882
|
+
* so a persona deleted off disk while she was the default does not degrade to the
|
|
883
|
+
* first row: it blocks EVERY new session, and the panel is not where the user
|
|
884
|
+
* would think to look. That is 「删掉人设后新开对话直接失败:agent-preset/not-found」.
|
|
885
|
+
*
|
|
886
|
+
* Note the sequence — `roster` is read FIRST because it only reports, while
|
|
887
|
+
* `status` (op: list) is the repair point. Asserting the report after the repair
|
|
888
|
+
* would assert nothing.
|
|
889
|
+
*/
|
|
890
|
+
const writesBeforeHeal = writes.length;
|
|
891
|
+
const healServices = {
|
|
892
|
+
settings: {
|
|
893
|
+
describe: () => [
|
|
894
|
+
{
|
|
895
|
+
ns: 'agent-preset-registry',
|
|
896
|
+
value: { selectedDefault: defaultId ?? '' },
|
|
897
|
+
revision: writes.length + 1,
|
|
898
|
+
},
|
|
899
|
+
],
|
|
900
|
+
update: async (ns, patch) => {
|
|
901
|
+
writes.push({ op: 'update', ns, patch });
|
|
902
|
+
defaultId = patch.selectedDefault;
|
|
903
|
+
},
|
|
904
|
+
mutate: async (ns, ops) => {
|
|
905
|
+
writes.push({ op: 'mutate', ns, ops });
|
|
906
|
+
defaultId = null;
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
configEditor: {
|
|
910
|
+
entries: () => [
|
|
911
|
+
{
|
|
912
|
+
options: {
|
|
913
|
+
id: 'agent-preset-registry',
|
|
914
|
+
name: '@deepseek-ai/dsh-agent-preset-registry',
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
],
|
|
918
|
+
edit: async () => {
|
|
919
|
+
throw new Error('configEditor.edit must not be the first choice any more');
|
|
920
|
+
},
|
|
921
|
+
},
|
|
922
|
+
};
|
|
923
|
+
services = healServices;
|
|
924
|
+
defaultId = 'ghost-wife';
|
|
925
|
+
check('the ghost really is not registered', !presets.has('ghost-wife'));
|
|
926
|
+
const ghostRoster = await call({ op: 'roster' });
|
|
927
|
+
check(
|
|
928
|
+
'the roster reports a default that names nothing as not resolving',
|
|
929
|
+
ghostRoster.defaultResolves === false,
|
|
930
|
+
JSON.stringify(ghostRoster.defaultResolves),
|
|
931
|
+
);
|
|
932
|
+
check(
|
|
933
|
+
'and no row is wearing the default badge',
|
|
934
|
+
ghostRoster.presets.every((row) => row.isDefault !== true),
|
|
935
|
+
JSON.stringify(ghostRoster.presets.filter((row) => row.isDefault === true)),
|
|
936
|
+
);
|
|
937
|
+
const healedPanel = await call({ op: 'list' });
|
|
938
|
+
check(
|
|
939
|
+
'opening the panel clears it through settings.mutate',
|
|
940
|
+
writes.length === writesBeforeHeal + 1 &&
|
|
941
|
+
writes.at(-1)?.op === 'mutate' &&
|
|
942
|
+
writes.at(-1)?.ops?.[0]?.op === 'unset' &&
|
|
943
|
+
writes.at(-1)?.ops?.[0]?.path?.[0] === 'selectedDefault',
|
|
944
|
+
JSON.stringify(writes.slice(writesBeforeHeal)),
|
|
945
|
+
);
|
|
946
|
+
check(
|
|
947
|
+
'the repaired default is gone from the registry',
|
|
948
|
+
defaultId === null,
|
|
949
|
+
JSON.stringify(defaultId),
|
|
950
|
+
);
|
|
951
|
+
check(
|
|
952
|
+
'the panel is told what was repaired, and why',
|
|
953
|
+
healedPanel.defaultHeal?.ok === true &&
|
|
954
|
+
healedPanel.defaultHeal.from === 'ghost-wife',
|
|
955
|
+
JSON.stringify(healedPanel.defaultHeal),
|
|
956
|
+
);
|
|
957
|
+
/*
|
|
958
|
+
* `nowDefault` is the id a new session will actually be stamped with. On this
|
|
959
|
+
* stub the roster has no `config.default` to fall back on, so it is `null` and
|
|
960
|
+
* the report says 「回落到 profile 默认」; a real profile resolves `cordis` through
|
|
961
|
+
* the roster faces and names it instead. Both are the same field.
|
|
962
|
+
*/
|
|
963
|
+
check(
|
|
964
|
+
'and it names what new sessions will use instead',
|
|
965
|
+
healedPanel.defaultHeal.nowDefault === null ||
|
|
966
|
+
typeof healedPanel.defaultHeal.nowDefault === 'string',
|
|
967
|
+
JSON.stringify(healedPanel.defaultHeal.nowDefault),
|
|
968
|
+
);
|
|
969
|
+
const healedTwice = await call({ op: 'list' });
|
|
970
|
+
check(
|
|
971
|
+
'a second open writes nothing more',
|
|
972
|
+
writes.length === writesBeforeHeal + 1,
|
|
973
|
+
String(writes.length),
|
|
974
|
+
);
|
|
975
|
+
check(
|
|
976
|
+
'and there is no repair left to report',
|
|
977
|
+
healedTwice.defaultHeal === null,
|
|
978
|
+
JSON.stringify(healedTwice.defaultHeal),
|
|
979
|
+
);
|
|
980
|
+
/*
|
|
981
|
+
* The other half of the contract: a default that DOES resolve must be left
|
|
982
|
+
* exactly where the user put it. A healer that clears a live default is worse
|
|
983
|
+
* than the bug it fixes.
|
|
984
|
+
*/
|
|
985
|
+
defaultId = 'alpha';
|
|
986
|
+
const keptDefault = await call({ op: 'list' });
|
|
987
|
+
check(
|
|
988
|
+
'a default that resolves is never touched',
|
|
989
|
+
writes.length === writesBeforeHeal + 1 && keptDefault.defaultId === 'alpha',
|
|
990
|
+
JSON.stringify({ writes: writes.length, id: keptDefault.defaultId }),
|
|
991
|
+
);
|
|
992
|
+
check('and nothing claims to have been repaired', keptDefault.defaultHeal === null);
|
|
993
|
+
/*
|
|
994
|
+
* The degrade path has to be loud. Without a `configEditor` there is nothing to
|
|
995
|
+
* write through, and the panel has to say the default is still broken — staying
|
|
996
|
+
* quiet here is how the next new session fails with no explanation again.
|
|
997
|
+
*/
|
|
998
|
+
services = { settings: healServices.settings };
|
|
999
|
+
defaultId = 'ghost-wife';
|
|
1000
|
+
const cannotHeal = await call({ op: 'list' });
|
|
1001
|
+
check(
|
|
1002
|
+
'a profile that cannot write keeps the broken default and says so',
|
|
1003
|
+
cannotHeal.defaultHeal?.ok === false &&
|
|
1004
|
+
String(cannotHeal.defaultHeal.reason).includes('configEditor'),
|
|
1005
|
+
JSON.stringify(cannotHeal.defaultHeal),
|
|
1006
|
+
);
|
|
1007
|
+
check(
|
|
1008
|
+
'and it did not pretend to have written anything',
|
|
1009
|
+
writes.length === writesBeforeHeal + 1 && defaultId === 'ghost-wife',
|
|
1010
|
+
JSON.stringify({ writes: writes.length, defaultId }),
|
|
1011
|
+
);
|
|
1012
|
+
/*
|
|
1013
|
+
* The other way this default goes dead: retiring her from the panel. `remove()`
|
|
1014
|
+
* archives the file, so the preset leaves the roster — and if she was the
|
|
1015
|
+
* default, the field is left naming nothing.
|
|
1016
|
+
*/
|
|
1017
|
+
services = healServices;
|
|
1018
|
+
const victim = await call({
|
|
1019
|
+
op: 'create',
|
|
1020
|
+
id: 'victim',
|
|
1021
|
+
name: 'Victim',
|
|
1022
|
+
body: 'Victim body.',
|
|
1023
|
+
});
|
|
1024
|
+
defaultId = 'victim';
|
|
1025
|
+
const removedDefault = await call({ op: 'remove', id: 'victim' });
|
|
1026
|
+
check(
|
|
1027
|
+
'retiring the default persona clears the default with it',
|
|
1028
|
+
removedDefault.defaultFollowed === 'cleared' &&
|
|
1029
|
+
removedDefault.defaultCleared === true &&
|
|
1030
|
+
defaultId === null,
|
|
1031
|
+
JSON.stringify({ removedDefault, defaultId }),
|
|
1032
|
+
);
|
|
1033
|
+
check(
|
|
1034
|
+
'and she is archived rather than deleted, as always',
|
|
1035
|
+
fs.existsSync(path.join(tmp, 'victim.md.off')),
|
|
1036
|
+
);
|
|
1037
|
+
/*
|
|
1038
|
+
* Blast radius: removing somebody else must not move the user's default. This is
|
|
1039
|
+
* the assertion that keeps `remove()` from becoming a general-purpose healer.
|
|
1040
|
+
*/
|
|
1041
|
+
const bystander = await call({
|
|
1042
|
+
op: 'create',
|
|
1043
|
+
id: 'bystander',
|
|
1044
|
+
name: 'Bystander',
|
|
1045
|
+
body: 'Bystander body.',
|
|
1046
|
+
});
|
|
1047
|
+
defaultId = 'alpha';
|
|
1048
|
+
const removedBystander = await call({ op: 'remove', id: 'bystander' });
|
|
1049
|
+
check(
|
|
1050
|
+
'retiring anyone else leaves the default where it is',
|
|
1051
|
+
removedBystander.defaultFollowed === 'unchanged' &&
|
|
1052
|
+
removedBystander.defaultCleared === undefined &&
|
|
1053
|
+
defaultId === 'alpha',
|
|
1054
|
+
JSON.stringify({ removedBystander, defaultId }),
|
|
1055
|
+
);
|
|
1056
|
+
services = {};
|
|
1057
|
+
defaultId = null;
|
|
1058
|
+
|
|
872
1059
|
console.log('--- the session already on screen is re-seated, not just future ones ---');
|
|
873
1060
|
/*
|
|
874
1061
|
* Writing `selectedDefault` decides what a session created LATER is stamped
|
|
@@ -1945,6 +2132,21 @@ check(
|
|
|
1945
2132
|
'the persona picker and the @ source are both wired',
|
|
1946
2133
|
clientSource.includes('commandUi.decorate') && clientSource.includes('registerSource'),
|
|
1947
2134
|
);
|
|
2135
|
+
/*
|
|
2136
|
+
* The repair report has three outcomes and the panel has to be able to draw all
|
|
2137
|
+
* three: cleared and falling back to the profile default, cleared onto a named
|
|
2138
|
+
* persona, and NOT cleared (which leaves new sessions still refusing to open, so
|
|
2139
|
+
* it is the one that must never be silent).
|
|
2140
|
+
*/
|
|
2141
|
+
check(
|
|
2142
|
+
'the repair report can draw every outcome it can be handed',
|
|
2143
|
+
clientSource.includes("t('defaultHealed')") &&
|
|
2144
|
+
clientSource.includes("t('defaultHealedNobody')") &&
|
|
2145
|
+
clientSource.includes("t('defaultHealedMoved')") &&
|
|
2146
|
+
clientSource.includes("t('defaultHealFailed')") &&
|
|
2147
|
+
clientSource.includes('data?.defaultHeal != null'),
|
|
2148
|
+
'the default-repair notice is not wired',
|
|
2149
|
+
);
|
|
1948
2150
|
check(
|
|
1949
2151
|
'every icon the panel asks for is in the bridge table',
|
|
1950
2152
|
(() => {
|