fix(hermes): restore profile and isolate group-chat run events

Group chat switched profiles during multi-agent sends but never
restored the user's active profile afterward, leaving Chat/Channels
on the wrong profile. matchesRun also accepted any hermes-run-done
while run_id was still null, so a concurrent run could leak output.

- Save initialProfile separately and restore in finally
- matchesHermesRun requires a known matching run_id
- Add regression tests

Co-authored-by: 晴天 <1186258278@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-06-01 11:02:55 +00:00
parent 38934fe754
commit 7ad95a2cce
2 changed files with 59 additions and 30 deletions

View File

@@ -32,6 +32,12 @@ import { svgIcon } from '../lib/svg-icons.js'
* 注意:并发场景下 listener 会全局收事件,因此用 run_id 过滤,
* 串行模式(当前群聊调度方式)也能 race-safe。
*/
/** @internal Exported for regression tests — do not accept events until run_id is known. */
export function matchesHermesRun(runId, eventRunId) {
return Boolean(runId && eventRunId && eventRunId === runId)
}
async function runHermesAgentAndWaitFinal(input) {
if (!isTauriRuntime()) {
throw new Error('Hermes group chat requires Tauri runtime')
@@ -58,27 +64,26 @@ async function runHermesAgentAndWaitFinal(input) {
cleanup()
reject(err)
}
const matchesRun = (rid) => !runId || !rid || rid === runId
;(async () => {
try {
unsubs.push(await safeTauriListen('hermes-run-started', (e) => {
if (!runId && e?.payload?.run_id) runId = e.payload.run_id
}))
unsubs.push(await safeTauriListen('hermes-run-delta', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
if (!matchesHermesRun(runId, e?.payload?.run_id)) return
accumulated += e?.payload?.delta || ''
}))
unsubs.push(await safeTauriListen('hermes-run-done', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
if (!matchesHermesRun(runId, e?.payload?.run_id)) return
const out = (e?.payload?.output || accumulated || '').trim()
finish(out)
}))
unsubs.push(await safeTauriListen('hermes-run-error', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
if (!matchesHermesRun(runId, e?.payload?.run_id)) return
fail(new Error(e?.payload?.error || 'unknown error'))
}))
unsubs.push(await safeTauriListen('hermes-run-cancelled', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
if (!matchesHermesRun(runId, e?.payload?.run_id)) return
finish(accumulated.trim() || '(cancelled)')
}))
@@ -302,39 +307,44 @@ export function render() {
// 简化策略:用 hermes_profile_use 切换 profile串行调度
// 每个 profile run 完后切到下一个。
// 这是个 trade-off — 真正的并发需要后端改造支持 per-call profile。
let activeProfile = null
let initialProfile = 'default'
let currentProfile = initialProfile
try {
// 记下当前 active profile 用于最后还原
const curResp = await api.hermesProfilesList().catch(() => null)
const curArr = Array.isArray(curResp) ? curResp : (curResp?.profiles || [])
activeProfile = curResp?.active || curArr.find(p => p.active)?.name || 'default'
} catch {}
initialProfile = curResp?.active || curArr.find(p => p.active)?.name || 'default'
currentProfile = initialProfile
} catch { /* keep default */ }
for (let i = 0; i < targets.length; i++) {
const profile = targets[i]
const placeholder = placeholders[i]
try {
// 切到该 profile
if (profile !== activeProfile) {
await api.hermesProfileUse(profile)
activeProfile = profile
try {
for (let i = 0; i < targets.length; i++) {
const profile = targets[i]
const placeholder = placeholders[i]
try {
if (profile !== currentProfile) {
await api.hermesProfileUse(profile)
currentProfile = profile
}
// 触发 agent run并通过 hermes-run-* 事件等真正的 final 输出。
// 不能直接用 hermesAgentRun 的返回值,它只是 run_id 字符串,不是回复内容。
const finalText = await runHermesAgentAndWaitFinal(text)
placeholder.loading = false
placeholder.content = finalText || t('engine.hermesGroupChatNoOutput')
placeholder.ts = Date.now()
} catch (e) {
placeholder.loading = false
placeholder.error = String(e?.message || e).slice(0, 500)
}
// 触发 agent run并通过 hermes-run-* 事件等真正的 final 输出。
// 不能直接用 hermesAgentRun 的返回值,它只是 run_id 字符串,不是回复内容。
const finalText = await runHermesAgentAndWaitFinal(text)
placeholder.loading = false
placeholder.content = finalText || t('engine.hermesGroupChatNoOutput')
placeholder.ts = Date.now()
} catch (e) {
placeholder.loading = false
placeholder.error = String(e?.message || e).slice(0, 500)
draw()
}
} finally {
// 还原进入群聊前的 active profile避免污染后续 Chat / Channels 等页面
if (currentProfile !== initialProfile) {
await api.hermesProfileUse(initialProfile).catch(() => {})
}
sending = false
draw()
}
// 还原 active profile如果改了— 静默尝试
sending = false
draw()
}
draw()

View File

@@ -0,0 +1,19 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { matchesHermesRun } from '../src/engines/hermes/pages/group-chat.js'
test('matchesHermesRun rejects events before run_id is known', () => {
assert.equal(matchesHermesRun(null, 'run_other'), false)
assert.equal(matchesHermesRun(undefined, 'run_other'), false)
assert.equal(matchesHermesRun('', 'run_other'), false)
})
test('matchesHermesRun rejects foreign run_id', () => {
assert.equal(matchesHermesRun('run_a', 'run_b'), false)
assert.equal(matchesHermesRun('run_a', null), false)
})
test('matchesHermesRun accepts only the same run_id', () => {
assert.equal(matchesHermesRun('run_a', 'run_a'), true)
})