diff --git a/src/adapters/cli/relay.ts b/src/adapters/cli/relay.ts index dba45e358..09e71ea78 100644 --- a/src/adapters/cli/relay.ts +++ b/src/adapters/cli/relay.ts @@ -35,6 +35,13 @@ export function createRelayAdapter(pathOverride?: string): CliAdapter { const dataDir = deriveRelayDataDir(); return createClaudeFamilyAdapter({ id: 'relay', + // Relay 是 Claude Code 的 fork,落盘 transcript 与 Claude Code 逐字同构: + // per-project JSONL 里带权威回合终态(最终 assistant `stop_reason:end_turn` + // 非工具停 + system 回合标记),与 claude-code 依赖的边界完全一致(在真实 + // ~/.relay/projects/*.jsonl 上核实)。因此它能诚实兑现同一份 turn-terminal + // 契约,opt-in 让 relay 系 bot 可当会议 agent(产出受管纪要/会中发言,靠这个 + // 权威边界终结投递回合、去重防同一投递外部副作用执行两次)。 + reliableTurnTerminal: true, // Relay's SuperRelay apiKey lives in `/byted-cloud-auth.json` // (NOT under bytedcli), and bytedcli SSO state lives under // ~/.local/share/bytedcli — keep BOTH real + writable inside the file sandbox diff --git a/src/adapters/cli/seed.ts b/src/adapters/cli/seed.ts index 73769ea26..d384a8a1a 100644 --- a/src/adapters/cli/seed.ts +++ b/src/adapters/cli/seed.ts @@ -48,6 +48,12 @@ export function createSeedAdapter(pathOverride?: string): CliAdapter { const dataDir = deriveSeedDataDir(bin); return createClaudeFamilyAdapter({ id: 'seed', + // Seed 是 Relay 的旧发行名,同一个 Claude Code fork 血统,落盘 transcript + // 与 Claude Code 同构(per-project JSONL 带权威 `stop_reason:end_turn` 终态 + + // system 回合标记)。turn-terminal 契约随 CLII 二进制血统一致(已在 relay 3.x + // 真实 transcript 上核实,seed 共享同一 JSONL 格式),opt-in 让 seed 系 bot 也 + // 能当会议 agent。 + reliableTurnTerminal: true, // Seed's SuperRelay apiKey lives in `/byted-cloud-auth.json` (NOT // under bytedcli); keep it + the bytedcli SSO dir real + writable in the file // sandbox so token refresh / login persist (a path not bound real wouldn't diff --git a/src/bot-registry.ts b/src/bot-registry.ts index 89dc44c73..4886ef019 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -310,7 +310,11 @@ function normalizeVcMeetingAgentConfig(raw: unknown): VcMeetingAgentConfig | und if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; const entry = raw as Record; const out: VcMeetingAgentConfig = {}; + // VC 默认对每个连着飞书的 bot 生效(vcMeetingAgentConfigActive:enabled!==false), + // 所以 enabled:false 是**显式退出**,必须原样保留——只留 true 会把 false round-trip + // 成 undefined(=默认开),让"关掉这个 bot 的会议"重载即失效。 if (entry.enabled === true) out.enabled = true; + else if (entry.enabled === false) out.enabled = false; const notificationChatId = normalizeNonEmptyString(entry.notificationChatId); const listenerChatId = normalizeNonEmptyString(entry.listenerChatId); const attentionTargetOpenId = normalizeNonEmptyString(entry.attentionTargetOpenId); @@ -350,6 +354,18 @@ function normalizeVcMeetingConsumerConfig(raw: unknown): VcMeetingConsumerConfig if (minBatchChars !== undefined) out.minBatchChars = minBatchChars; if (minBatchItems !== undefined) out.minBatchItems = minBatchItems; if (maxInjectIntervalMs !== undefined) out.maxInjectIntervalMs = maxInjectIntervalMs; + if (entry.textOutputPolicy === 'allow' || entry.textOutputPolicy === 'approval' || entry.textOutputPolicy === 'deny') { + out.textOutputPolicy = entry.textOutputPolicy; + } + if (entry.voiceOutputPolicy === 'allow' || entry.voiceOutputPolicy === 'approval' || entry.voiceOutputPolicy === 'deny') { + out.voiceOutputPolicy = entry.voiceOutputPolicy; + } + + // per-bot 从共享目录挑的默认角色。与 consumerProfiles 无关(bot 继承目录、不拥有 + // 预设),故无条件归一化,不触发 legacy "consumerProfiles required" resolver 门。 + // 空串/空白 = 「跟随全局默认」,等同没配。 + const catalogDefaultConsumerId = normalizeNonEmptyString(entry.catalogDefaultConsumerId); + if (catalogDefaultConsumerId) out.catalogDefaultConsumerId = catalogDefaultConsumerId; if (Object.prototype.hasOwnProperty.call(entry, 'defaultProfileBootstrap')) { const marker = entry.defaultProfileBootstrap; @@ -546,7 +562,7 @@ function normalizeVcMeetingListenerDelivery( return { placement: entry.placement as 'auto' | 'chat' | 'topic' }; } -function normalizeVcMeetingConsumerProfiles(raw: unknown): VcMeetingConsumerProfileConfig[] { +export function normalizeVcMeetingConsumerProfiles(raw: unknown): VcMeetingConsumerProfileConfig[] { const path = 'vcMeetingAgent.meetingConsumer.consumerProfiles'; if (!Array.isArray(raw)) strictConfigError(path, 'must be an array'); return raw.map((value, index) => { @@ -783,7 +799,10 @@ function normalizeVcMeetingRealtimeVoiceConfig(raw: unknown): VcMeetingRealtimeV if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; const entry = raw as Record; const out: VcMeetingRealtimeVoiceConfig = {}; + // 实时语音默认开启(vcMeetingRealtimeVoiceEnabled:enabled!==false),enabled:false 是 + // 显式关闭,必须保留——只留 true 会让"关掉实时语音"round-trip 成 undefined(=默认开)。 if (entry.enabled === true) out.enabled = true; + else if (entry.enabled === false) out.enabled = false; const sampleRate = normalizePositiveInt(entry.sampleRate); const channels = normalizePositiveInt(entry.channels); const frameMs = normalizePositiveInt(entry.frameMs); @@ -1941,8 +1960,18 @@ export function vcMeetingAgentConfigActive( cfg: Pick | undefined, ): VcMeetingAgentConfig | undefined { if (!cfg) return undefined; + // apiOnly (core-only) bots have no Feishu transport — a VC listener drives + // `lark-cli vc +meeting-events --as bot`, which breaks the zero-Feishu-network + // contract. This fail-close is the load-bearing invariant and must stay first. if (cfg.apiOnly === true) return undefined; - return cfg.vcMeetingAgent?.enabled === true ? cfg.vcMeetingAgent : undefined; + // Bot-agnostic join (2026-08): any invited bot should join, so VC is active by + // default for every Feishu-connected bot. `vcMeetingAgent.enabled: false` is + // the explicit per-bot opt-out; unset/absent now means active. A bot with no + // vcMeetingAgent block at all gets an empty effective config so downstream + // reads (listenerChatId auto-create, profile provision, consumer defaults) + // work off their own fallbacks. Fleet-wide off remains the global switch. + if (cfg.vcMeetingAgent?.enabled === false) return undefined; + return cfg.vcMeetingAgent ?? {}; } export function registerBot(cfg: BotConfig): BotState { diff --git a/src/cli/vc-agent.ts b/src/cli/vc-agent.ts index f50ead449..391cabeb0 100644 --- a/src/cli/vc-agent.ts +++ b/src/cli/vc-agent.ts @@ -18,6 +18,7 @@ import { import { loadBotConfigs, registerBot } from '../bot-registry.js'; import { config } from '../config.js'; import { listVcMeetingRuntimeSessions } from '../services/vc-meeting-runtime-store.js'; +import { latestVcMeetingDeliveryForSession } from '../services/vc-meeting-delivery-store.js'; import { findOnlineDaemon } from '../utils/daemon-discovery.js'; import { resolveSessionContext } from '../core/session-marker.js'; import { fetchDaemonIpc } from '../core/daemon-ipc-auth.js'; @@ -299,6 +300,23 @@ async function cmdRequestOutput(args: string[]): Promise { relayDir, process.env.BOTMUX_ORIGIN_CHANNEL_ID, )?.capability; + // The live process-tree marker only carries turnId/dispatchAttempt WHILE a + // delivery turn is executing; both are cleared when the turn goes idle. A + // meeting agent typically decides to speak AFTER it has finished processing + // the transcript (turn already terminal), so liveOrigin is usually empty here. + // Recover the delivery identity of the turn it just processed from the durable + // ledger (keyed only by sessionId) so the daemon can re-authorize in-meeting + // output against the completed receipt. The live marker still takes precedence + // when present (an in-flight turn). + let originTurnId = liveOrigin?.turnId; + let originDispatchAttempt = liveOrigin?.dispatchAttempt; + if ((originTurnId === undefined || originDispatchAttempt === undefined) && receiverSessionId) { + const durable = latestVcMeetingDeliveryForSession(config.session.dataDir, receiverSessionId); + if (durable) { + originTurnId ??= durable.receipt.deliveryKey; + originDispatchAttempt ??= durable.receipt.dispatchAttempt; + } + } const discoveredReceiver = receiverAppId ? findOnlineDaemon(receiverAppId) : null; const receiverPort = Number.isSafeInteger(receiverPortRaw) && receiverPortRaw > 0 ? receiverPortRaw @@ -315,9 +333,9 @@ async function cmdRequestOutput(args: string[]): Promise { content, ...(reason ? { reason } : {}), ...(fallbackText ? { fallbackText } : {}), - ...(liveOrigin?.turnId ? { originTurnId: liveOrigin.turnId } : {}), - ...(liveOrigin?.dispatchAttempt !== undefined - ? { originDispatchAttempt: liveOrigin.dispatchAttempt } + ...(originTurnId ? { originTurnId } : {}), + ...(originDispatchAttempt !== undefined + ? { originDispatchAttempt } : {}), ...(originCapability ? { originCapability } : {}), }), diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index fdf86c7e9..4c366e178 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -1234,7 +1234,9 @@ export async function handleCardCommand( } ds.streamingCardForced = true; const posted = await postFreshStreamingCard(ds, deps.sessionReply); - if (!posted) await reply(t('cmd.card.not_ready', undefined, loc)); + if (!posted) { + await reply(t('cmd.card.not_ready', undefined, loc)); + } return; } diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index e3c201e86..3d8709a21 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -2720,18 +2720,10 @@ ipcRoute('POST', '/api/trigger', async (req, res) => { error: `request target botId ${valid.request.target.botId} does not match daemon ${cachedLarkAppId}`, }); } - if (valid.request.target.kind === 'turn' && valid.request.target.sessionId) { - const receiverTarget = [...activeSessions.values()].find( - (candidate) => candidate.session.sessionId === valid.request.target.sessionId, - ); - if (receiverTarget?.session.vcMeetingReceiver) { - return jsonRes(res, 403, { - ok: false, - errorCode: 'managed_receiver_requires_delivery_endpoint', - error: 'dedicated meeting receiver sessions accept only fenced delivery or explicit IM routing', - }); - } - } + // Plan B: a VC meeting agent is an ordinary chat-scope session, so the generic + // trigger endpoint may address it like any session (botmux send / dashboard). + // Meeting transcript deliveries still flow through their own fenced delivery + // path — this endpoint only ever carries ordinary user-initiated turns. try { if (valid.request.target.kind === 'workflow') { return jsonRes(res, 410, { diff --git a/src/core/dashboard-rows.ts b/src/core/dashboard-rows.ts index 8aa658f96..5871a1b0a 100644 --- a/src/core/dashboard-rows.ts +++ b/src/core/dashboard-rows.ts @@ -208,9 +208,15 @@ export function composeRowFromActive(ds: DaemonSession, opts?: { fresh?: boolean // For every other session, process residency is authoritative: suspension // clears ds.worker but intentionally preserves the logical active session. // Never let a stale pre-suspend status make it look resident after hydrate. + // No screen status yet + worker init complete = the CLI is executing its + // first turn (screen updates are suppressed until the first idle prompt). + // Long first turns — e.g. meeting agents fed a transcript delivery right at + // spawn — previously sat in「启动中」for minutes; project them as working. status: ds.session.queued ? 'idle' - : (!ds.worker || ds.worker.killed ? 'dormant' : (ds.lastScreenStatus ?? 'starting')), + : (!ds.worker || ds.worker.killed + ? 'dormant' + : (ds.lastScreenStatus ?? (ds.workerReady === true ? 'working' : 'starting'))), adopt: !!ds.adoptedFrom, spawnedAt: sessionCreatedAtMs(ds.session) || ds.spawnedAt, lastMessageAt: sessionLastActivityAtMs(ds.session) || ds.lastMessageAt, diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 90a4e09d7..4ee07f0db 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -278,10 +278,11 @@ export async function foldableChatSessionAppIds(input: { || session.scope !== 'chat' || !session.larkAppId || session.chatId !== input.targetChatId - // These chat-scoped sessions deliberately use isolated routing keys and - // can never be reached through the ordinary (chatId, appId) slot. - || session.deferredScheduleRun - || session.vcMeetingReceiver) continue; + // A deferred scheduled run deliberately uses an isolated routing key and + // can never be reached through the ordinary (chatId, appId) slot. (A VC + // meeting agent is now an ordinary chat-scope session — Plan B — so it IS + // foldable and is intentionally NOT excluded here.) + || session.deferredScheduleRun) continue; candidates.add(session.larkAppId); } diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 44d287430..55529b996 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -2508,9 +2508,6 @@ export async function ensureTerminalWorkerPort(ds: DaemonSession): Promise, ): Promise<{ ok: true; ds: DaemonSession } -| { ok: false; error: 'not_found' | 'not_closed' | 'anchor_occupied' | 'adopt_unsupported' | 'vc_receiver_managed' | 'deferred_unmaterialized' | 'resume_cancelled'; activeSessionId?: string }> { +| { ok: false; error: 'not_found' | 'not_closed' | 'anchor_occupied' | 'adopt_unsupported' | 'deferred_unmaterialized' | 'resume_cancelled'; activeSessionId?: string }> { let session = sessionStore.getSession(sessionId); if (!session) return { ok: false, error: 'not_found' }; if (session.status !== 'closed') return { ok: false, error: 'not_closed' }; - // A dedicated VC receiver is not an ordinary chat conversation. Its - // identity is fenced by (meeting, member, epoch) and its active-map slot is - // reconstructed by the meeting hub/membership lifecycle. Reactivating a - // closed receiver through the generic dashboard/card/CLI path would bypass - // that ownership check, potentially revive a stale epoch, and (before the - // dedicated-key fix) collapse it into the listener chat's ordinary slot. - // Keep it closed and let the authoritative meeting lifecycle create or - // recover the correct receiver binding. - if (session.vcMeetingReceiver) { - return { ok: false, error: 'vc_receiver_managed' }; - } + // Plan B: a VC meeting agent is an ordinary chat-scope session, so a closed one + // resumes into its normal (chatId, appId) slot like any chat session — the old + // `vc_receiver_managed` refusal is gone. (Reviving into the ordinary slot is now + // the intended behavior, not a hazard; the meeting lifecycle re-stamps the + // delivery binding via ensureVcMeetingReceiverSession when the meeting is live.) // Auto-closed invisible schedule runs are audit records, not conversations. // Without a materialized binding, resuming one would wake a virtual chat- @@ -2563,7 +2554,6 @@ export async function resumeSession( const latest = sessionStore.getSession(sessionId); if (!latest) return { ok: false as const, error: 'not_found' as const }; if (latest.status !== 'closed') return { ok: false as const, error: 'not_closed' as const }; - if (latest.vcMeetingReceiver) return { ok: false as const, error: 'vc_receiver_managed' as const }; if (latest.deferredScheduleRun && !readDeferredTopicBinding(config.session.dataDir, latest.sessionId)) { return { ok: false as const, error: 'deferred_unmaterialized' as const }; diff --git a/src/core/types.ts b/src/core/types.ts index 5a97fb433..e4cdf0492 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -572,15 +572,13 @@ export function storedSessionAnchorId( ?? (session.scope === 'chat' ? session.chatId : session.rootMessageId); } -/** Storage key for the daemon-owned activeSessions map. A VC receiver is a - * dedicated conversation even though its visible output route is a chat, so - * key it by its immutable session id instead of collapsing it into the normal - * `(chatId, appId)` chat-scope slot. */ +/** Storage key for the daemon-owned activeSessions map. A VC meeting agent is + * now an ordinary chat-scope session in its listener group (Plan B): it is keyed + * by the normal `(chatId, appId)` slot so plain IM and meeting transcripts both + * fold into the one session. The `vcMeetingReceiver` marker is retained as pure + * delivery/meeting-output metadata and no longer affects routing. */ export function activeSessionKey(ds: DaemonSession): string { - const anchor = ds.session.vcMeetingReceiver - ? `vc-receiver:${ds.session.sessionId}` - : sessionAnchorId(ds); - return sessionKey(anchor, ds.larkAppId); + return sessionKey(sessionAnchorId(ds), ds.larkAppId); } /** A session whose only IM surface is a Feishu document comment thread. diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index e17897476..6e13f31b8 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -734,7 +734,7 @@ function flushPendingLocalCliOpenReadinessPatch(ds: DaemonSession): void { /** PATCH the live card when the executor reports a different active runtime. * Runtime identity stays attached to the streaming usage line. */ function scheduleActiveRuntimePatch(ds: DaemonSession): void { - if (ds.session.vcMeetingReceiver || streamingCardDisabled(ds) || ds.suppressRecoveryCard) { + if (streamingCardDisabled(ds) || ds.suppressRecoveryCard) { ds.pendingActiveRuntimeCardRefresh = undefined; return; } @@ -786,7 +786,7 @@ function flushPendingActiveRuntimePatch(ds: DaemonSession): void { /** PATCH a live card when rollout settings change, even if the PTY is static. */ function scheduleCodexServiceTierPatch(ds: DaemonSession): void { - if (ds.session.vcMeetingReceiver || streamingCardDisabled(ds) || ds.suppressRecoveryCard) { + if (streamingCardDisabled(ds) || ds.suppressRecoveryCard) { ds.pendingCodexTierCardRefresh = undefined; return; } @@ -1183,10 +1183,6 @@ export function cardUsageLimit(ds: DaemonSession): CliUsageLimitState | undefine } function scheduleUsageLimitCardPatch(ds: DaemonSession): void { - // Dedicated VC receivers keep limit state for dashboard/audit only. A timer - // must never revive or mutate an old/manual Lark card after the synchronous - // screen_update path has already suppressed auxiliary UI. - if (ds.session.vcMeetingReceiver) return; if (ds.lastScreenStatus !== 'limited') return; if (!ds.streamCardId || ds.streamCardId === CARD_POSTING_SENTINEL || !workerHasInitialized(ds)) return; @@ -1375,7 +1371,7 @@ export async function postTurnStartingCard( if (!ds.streamCardPending || ds.streamCardPendingTurnId !== turnId) return false; if (riffRetirementAdmissionPhase(ds)) return false; if (ds.streamCardId === CARD_POSTING_SENTINEL) return false; - if (ds.session.vcMeetingReceiver || streamingCardDisabled(ds, turnId)) return false; + if (streamingCardDisabled(ds, turnId)) return false; if (!workerHasInitialized(ds)) return false; if (!larkTransportEnabled({ chatId: ds.chatId, apiOnly: getBot(ds.larkAppId).config.apiOnly })) return false; @@ -1498,9 +1494,6 @@ export async function postFreshStreamingCard( ds: DaemonSession, sessionReply: (rootId: string, content: string, msgType?: string, larkAppId?: string, turnId?: string) => Promise, ): Promise { - // Receiver terminals can contain meeting-derived private context. Never - // publish one into the listener chat as a streaming-card side channel. - if (ds.session.vcMeetingReceiver) return false; if (isDocNativeSession(ds)) return false; if (!workerHasInitialized(ds)) return false; const botCfg = getBot(ds.larkAppId).config; @@ -3706,10 +3699,11 @@ function persistedActiveSessionKey( fallbackLarkAppId: string, ): string { const larkAppId = session.larkAppId ?? fallbackLarkAppId; - const anchor = session.vcMeetingReceiver - ? `vc-receiver:${session.sessionId}` - : storedSessionAnchorId(session); - return sessionKey(anchor, larkAppId); + // Plan B: a VC meeting agent is an ordinary chat-scope session keyed by its + // normal chat anchor — this must stay in lockstep with activeSessionKey() + // (core/types.ts) so a restored meeting row re-registers at the SAME slot the + // live path uses. The vcMeetingReceiver marker is delivery metadata only. + return sessionKey(storedSessionAnchorId(session), larkAppId); } /** @@ -4018,7 +4012,13 @@ function failOrdinaryImDelivery(record: OrdinaryImDelivery, reason: string): voi + `turn=${record.turnId.substring(0, 16)} generation=${record.workerGeneration} ` + `attempts=${record.attempt} reason=${reason}`, ); - if (record.ds.session.vcMeetingReceiver || isSilentScheduledTurn(record.ds, record.turnId)) return; + // Plan B: a meeting agent is an ordinary chat-scope session. This tracker only + // ever holds ORDINARY IM turns (durable transcript deliveries use the receipt + // path, not this map), so a plain user turn's delivery failure must be reported + // like any session — otherwise the user's message is silently dropped. Only a + // stamped meeting @mention follow-up stays fenced (isMeetingDrivenTurn); a + // silent scheduled turn stays suppressed as before. + if (isMeetingDrivenTurn(record.ds, record.turnId) || isSilentScheduledTurn(record.ds, record.turnId)) return; const loc = botLocale(getBot(record.ds.larkAppId).config); void requireCallbacks().sessionReply( sessionAnchorId(record.ds), @@ -4132,7 +4132,12 @@ function shouldTrackOrdinaryImDelivery( && message.dispatchAttempt === undefined && (message.type !== 'init' || (!!message.prompt && !message.adoptMode)) && !ds.adoptedFrom - && !ds.session.vcMeetingReceiver + // Plan B: a meeting agent is an ordinary chat-scope session. A plain user + // turn on it IS an ordinary IM delivery (track it for receipt-ACK + retry so + // its failure is reported). Only a meeting-driven turn — a stamped @mention + // follow-up here, since durable deliveries are already excluded by the + // dispatchAttempt===undefined guard above — stays on the receipt/lease path. + && !isMeetingDrivenTurn(ds, message.turnId, message.dispatchAttempt) && Number.isSafeInteger(ds.workerGeneration) && (ds.workerGeneration ?? 0) > 0 && ds.session.workerGeneration === ds.workerGeneration; @@ -4540,7 +4545,6 @@ export async function transferSession( runtimeWorkingDir: ds.workingDir, runtimeAdoptedFrom: ds.adoptedFrom, }); - if (ds.session.vcMeetingReceiver) return { ok: false, error: 'vc_receiver_not_relayable' }; if (hasProtectedSessionMutationOwnership(ds)) { return { ok: false, error: 'codex_app_dispatch_pending' }; } @@ -4694,7 +4698,6 @@ export async function transferSession( item.sessionId !== ds.session.sessionId && item.status === 'active' && (!item.larkAppId || item.larkAppId === ds.larkAppId) - && !item.vcMeetingReceiver && (item.scope === 'chat' ? item.chatId : item.rootMessageId) === targetAnchor && !runtimeIds.has(item.sessionId), ); @@ -4968,7 +4971,6 @@ export async function forkSession( // ── Front guards (mirror transferSession; a fork needs a clean, complete // source node exactly as a relay does) ── - if (ds.session.vcMeetingReceiver) return { ok: false, error: 'vc_receiver_not_forkable' }; if (ds.pendingRepo) return { ok: false, error: 'not_started_yet' }; if (!isRelayableRealSession(ds)) return { ok: false, error: 'not_started_yet' }; if (ds.session.adoptedFrom) return { ok: false, error: 'adopt_not_forkable' }; @@ -6765,10 +6767,18 @@ export function forkWorker( reason: 'worker_fork_error', message: reason, }); - // A dedicated VC receiver and a silent schedule have no auxiliary Lark - // output channel. Keep lifecycle/dashboard state above, but never leak a - // fork diagnostic into the chat. - if (ds.session.vcMeetingReceiver || isSilentScheduledTurn(ds, initAttributionTurnId)) { + // Plan B: a meeting agent is an ordinary chat-scope session. A fork + // diagnostic must stay out of the chat only for a genuinely meeting-driven + // opening — a durable transcript delivery (initDispatchAttempt set) or a + // stamped meeting @mention follow-up — because that path is fenced to the + // receipt/lease chain and could otherwise leak on a silent delivery. A plain + // user turn on this session gets its fork failure surfaced like any session + // (else the user's own turn fails silently). Silent scheduled turns stay + // suppressed as before. + const forkErrorMeetingDriven = !!ds.session.vcMeetingReceiver + && (initDispatchAttempt !== undefined + || resolveVcMeetingImTurnOrigin(ds.session, initAttributionTurnId) !== undefined); + if (forkErrorMeetingDriven || isSilentScheduledTurn(ds, initAttributionTurnId)) { logger.info( `[${t}] Managed/silent fork failure kept out of auxiliary Lark UI ` + `turn=${initAttributionTurnId?.slice(0, 12) ?? '-'} attempt=${initDispatchAttempt}: ${reason}`, @@ -6783,7 +6793,9 @@ export function forkWorker( 'text', ds.larkAppId, fallbackTurnId(ds, initAttributionTurnId), - ds.session.vcMeetingReceiver + // A meeting-driven turn (listener_thread delivery) that DOES surface must + // still attribute to the meeting session so the send policy resolves it. + forkErrorMeetingDriven ? { sourceSessionId: ds.session.sessionId } : undefined, ).catch(replyErr => logger.error(`[${t}] Failed to deliver worker fork error to Lark: ${replyErr}`)); @@ -7168,6 +7180,27 @@ function invalidateTuiPrompt( publishAttentionPatch(ds); } +/** + * Plan B: a VC meeting agent is an ordinary chat-scope session that hosts BOTH + * meeting transcript deliveries and plain user IM turns. A turn is + * "meeting-driven" only when it is a durable transcript delivery (dispatchAttempt + * set) or a stamped meeting @mention follow-up (vcMeetingImTurnOrigin). Only + * meeting-driven turns are subject to the durable silent/listener_thread output + * policy and the receipt-fenced auxiliary-UI suppression; a plain user turn on + * this session is an ordinary turn and must post / notify normally (else the + * user's own reply is silently swallowed — the "手动@机器人也不回复" bug). Shared + * across setupWorkerHandlers and deliverFinalOutput so every gate stays aligned. + */ +function isMeetingDrivenTurn( + ds: DaemonSession, + turnId?: string, + dispatchAttempt?: number, +): boolean { + if (!ds.session.vcMeetingReceiver) return false; + if (dispatchAttempt !== undefined) return true; + return resolveVcMeetingImTurnOrigin(ds.session, turnId) !== undefined; +} + function setupWorkerHandlers( ds: DaemonSession, worker: ChildProcess, @@ -7269,7 +7302,17 @@ function setupWorkerHandlers( // still updated before these guards, so the terminal view is unaffected. if (!larkTransportEnabled({ chatId: ds.chatId, apiOnly: getBot(ds.larkAppId).config.apiOnly })) return true; if (isSilentScheduledTurn(ds, turnId)) return true; - if (ds.session.vcMeetingReceiver) return true; + // Plan B: a VC meeting agent is an ordinary chat-scope session, so the live + // STREAMING CARD is no longer gated here at all — it surfaces through the + // normal post/patch path like any group session (that was the "看不到流式 + // 卡片" report; those sites dropped their VC guard). What remains funnelled + // through managedAuxUiSuppressed is out-of-band auxiliary UI (startup-failure + // notices, usage-limit patches, exit UI): for a genuinely meeting-driven turn + // — a durable transcript delivery (dispatchAttempt set) or a stamped meeting + // @mention follow-up — that diagnostic stays fenced to the receipt/retry + // chain and must never leak out-of-band (a silent delivery especially). A + // plain user turn on this session has neither marker and notifies normally. + if (isMeetingDrivenTurn(ds, turnId, dispatchAttempt)) return true; return ordinaryManagedSuppression(turnId, dispatchAttempt); }; /** final_output is the sole exception: listener_thread and exact IM replies @@ -7280,13 +7323,16 @@ function setupWorkerHandlers( ): boolean => { if (isSilentScheduledTurn(ds, turnId)) return true; if (isTriggerFinalSuppressed(ds, turnId)) return true; - if (!ds.session.vcMeetingReceiver) { + // Only a genuinely meeting-driven turn is subject to the durable meeting-send + // policy; a plain user turn on this session posts through the ordinary path + // (see isMeetingDrivenTurn). + if (!isMeetingDrivenTurn(ds, turnId, dispatchAttempt)) { return ordinaryManagedSuppression(turnId, dispatchAttempt); } // Resolve every Lark-facing worker event against durable origin state. The // receipt freezes responseMode, so terminal→idle updates and daemon restore // cannot become loud merely because an in-memory suppression map was - // cleared/lost. Missing attribution on a dedicated receiver fails closed. + // cleared/lost. Missing attribution on a meeting-driven turn fails closed. const decision = evaluateVcMeetingManagedSend(config.session.dataDir, { receiverSessionId: ds.session.sessionId, receiverSession: true, @@ -9684,9 +9730,11 @@ async function finishTurnReactions(ds: DaemonSession): Promise { if (!list || list.length === 0) return; // Detach the batch first so a second idle edge can't double-flip it. ds.pendingAckReactions = []; - // A dedicated receiver has no progress-reaction channel. Clear any stale - // in-memory entries restored from an older build without touching Lark. - if (ds.session.vcMeetingReceiver) return; + // Plan B: a meeting agent is an ordinary chat-scope session. Pending ack + // reactions only ever exist for a real inbound user message (the ✋ is placed + // when that message arrives — transcript deliveries have no inbound message to + // react to), so a non-empty list here always belongs to a plain user turn and + // must settle to ✅ like any session. No VC special-case. const silent = silentTurnReactions(ds); const doneEmoji = doneReactionEmojiFor(ds); for (const ack of list) { @@ -9903,7 +9951,14 @@ function deliverFinalOutput( const imOrigin = msg.dispatchAttempt === undefined ? resolveVcMeetingImTurnOrigin(ds.session, msg.turnId) : undefined; - const managedDecision = ds.session.vcMeetingReceiver + // Plan B: the meeting agent is an ordinary chat-scope session, so it also + // delivers plain user turns. Apply the durable meeting-send policy ONLY to + // a genuinely meeting-driven turn (a durable transcript delivery, or a + // stamped meeting @mention follow-up). A plain user turn is not + // meeting-driven → managedDecision stays undefined → it delivers through + // the ordinary path. Without this gate a plain user reply resolves + // `origin_unproven` and is silently dropped (the "手动@机器人也不回复" bug). + const managedDecision = isMeetingDrivenTurn(ds, msg.turnId, msg.dispatchAttempt) ? evaluateVcMeetingManagedSend(config.session.dataDir, { receiverSessionId: ds.session.sessionId, receiverSession: true, @@ -9923,7 +9978,7 @@ function deliverFinalOutput( return; } const revalidateManagedSend = (): void => { - if (!ds.session.vcMeetingReceiver) return; + if (!isMeetingDrivenTurn(ds, msg.turnId, msg.dispatchAttempt)) return; const current = evaluateVcMeetingManagedSend(config.session.dataDir, { receiverSessionId: ds.session.sessionId, receiverSession: true, diff --git a/src/daemon.ts b/src/daemon.ts index 4d221a7dc..cdcd89c31 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -394,7 +394,7 @@ function republishResolvedAllowedUsers(larkAppId: string, resolved: string[]): v try { writeDaemonDescriptor(desc); } catch { /* best effort */ } } let vcMeetingTerminalReconciler: VcMeetingTerminalReconciler | undefined; -import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, markForwardFollowupsSessionsReady, writeBotInfoFile, canOperate, canRunDaemonCommand, evaluateTalk, evaluateBotTalk, evaluateAskAnswerTalk, grantCommandRestriction, isKnownPeerBot, checkRequiredScopes, type RoutingContext, type TalkEvaluation, type DocCommentContext, type EventHandlers } from './im/lark/event-dispatcher.js'; +import { isBotMentioned, probeBotOpenId, startLarkEventDispatcher, markForwardFollowupsSessionsReady, writeBotInfoFile, canOperate, canRunDaemonCommand, evaluateTalk, evaluateBotTalk, evaluateAskAnswerTalk, grantCommandRestriction, isKnownPeerBot, checkRequiredScopes, ensureVcMeetingEventsSubscribed, type RoutingContext, type TalkEvaluation, type DocCommentContext, type EventHandlers } from './im/lark/event-dispatcher.js'; import { getDocSubscription, listAllDocSubscriptions, listDocSubscriptionsForSession, putDocSubscription, removeDocSubscription, setDocCommentPollCursor, type DocSubscription } from './services/doc-subs-store.js'; import { BOT_REPLY_SENTINEL, subscribeDocFile, unsubscribeDocFile, addCommentReaction, removeCommentReaction, hasBotSentinel, isBotAuthoredReply, listDocComments } from './im/lark/doc-comment.js'; import { learnFromMentions, resolveSender, flushIdentityCacheSync, type ResolvedSender } from './im/lark/identity-cache.js'; @@ -494,7 +494,7 @@ import { computeVcMeetingConsumerProfileHash, normalizeVcMeetingProfileInstructions, } from './services/vc-meeting-profile-instructions.js'; -import { bootstrapVcMeetingDefaultConsumerProfile } from './services/vc-meeting-consumer-profile-bootstrap.js'; +import { bindVcMeetingConsumerCatalogToBot } from './services/vc-meeting-shared-consumer-catalog.js'; import { getVcMeetingPreparation, normalizeVcMeetingNumber, @@ -566,6 +566,7 @@ import { VcMeetingTerminalReconciler } from './services/vc-meeting-terminal-reco import { resolveVcMeetingImTurnOrigin, verifyVcMeetingManagedOriginClaim, + evaluateVcMeetingManagedSend, } from './services/vc-meeting-send-policy.js'; import { ensureVcMeetingListenerTopicRoot, @@ -1830,16 +1831,38 @@ const vcMeetingClosingConsumerSessions = new Map(); let vcMeetingAgentGlobalEnabledOverrideForTest: boolean | undefined; let vcMeetingAgentGlobalListenerBotAppIdOverrideForTest: string | undefined | null; +let vcMeetingListenerPinDeprecationWarned = false; function vcMeetingAgentGlobalEnabled(): boolean { return vcMeetingAgentGlobalEnabledOverrideForTest ?? isVcMeetingAgentGloballyEnabled(); } +/** + * RETIRED (2026-08): the global VC listener pin is gone — every bot with + * `vcMeetingAgent.enabled` now handles the meeting events IT receives, so a + * meeting can run multiple bots (each with its own presets) instead of routing + * everything through one designated listener. Always returns undefined so the + * `listenerAppId &&` guards at every call site short-circuit to the + * every-bot-handles-its-own behaviour (which was already the semantics when + * `listenerBotAppId` was unset). A legacy `vcMeetingAgent.listenerBotAppId` + * still in config is ignored with a one-time warning. + * + * The test override is retained ONLY so the test harness can still call the + * setter without a signature change; it no longer re-pins routing. + */ function vcMeetingAgentGlobalListenerAppId(): string | undefined { - if (vcMeetingAgentGlobalListenerBotAppIdOverrideForTest !== undefined) { - return vcMeetingAgentGlobalListenerBotAppIdOverrideForTest ?? undefined; + const legacyPin = vcMeetingAgentGlobalListenerBotAppIdOverrideForTest !== undefined + ? (vcMeetingAgentGlobalListenerBotAppIdOverrideForTest ?? undefined) + : vcMeetingAgentGlobalListenerBotAppId(); + if (legacyPin && !vcMeetingListenerPinDeprecationWarned) { + vcMeetingListenerPinDeprecationWarned = true; + logger.warn( + `[vc-agent] vcMeetingAgent.listenerBotAppId=${legacyPin} is deprecated and ignored: ` + + 'every bot with vcMeetingAgent.enabled now handles its own meeting invites. ' + + 'Remove this field from the global config.', + ); } - return vcMeetingAgentGlobalListenerBotAppId(); + return undefined; } function vcMeetingPushIsTrackedLifecycleEvent(ctx: VcMeetingPushContext): boolean { @@ -1857,7 +1880,11 @@ const DEFAULT_VC_MEETING_FLUSH_INTERVAL_MS = 30_000; const DEFAULT_VC_MEETING_TIME_ZONE = 'Asia/Shanghai'; const DEFAULT_VC_MEETING_CONSUMER_SELECTION_TIMEOUT_MS = 20_000; const DEFAULT_VC_MEETING_CONSUMER_INJECT_INTERVAL_MS = 30_000; -const DEFAULT_VC_MEETING_CONSUMER_MIN_BATCH_CHARS = 400; +// Effectively no char-accumulation gate: with minBatchItems=1 a single +// finalized transcript segment already qualifies, and the operator explicitly +// asked for no "wait until enough text piles up" behavior (esp. at meeting +// start). Kept as a config knob for bots that want to raise it. +const DEFAULT_VC_MEETING_CONSUMER_MIN_BATCH_CHARS = 1; const VC_MEETING_CONSUMER_DELIVERY_MAX_ITEMS = 100; const VC_MEETING_CONSUMER_DELIVERY_MAX_RENDERED_CHARS = 20_000; // By default, keep the selected agent as fresh as the listener group: one new @@ -2077,12 +2104,38 @@ function vcMeetingConsumerMaxInjectIntervalMs(cfg: VcMeetingAgentConfig): number return cfg.meetingConsumer?.maxInjectIntervalMs ?? DEFAULT_VC_MEETING_CONSUMER_MAX_INJECT_INTERVAL_MS; } -function defaultVcMeetingTextOutputPolicy(): VcMeetingOutputPolicy { - return 'approval'; +function defaultVcMeetingTextOutputPolicy(cfg?: VcMeetingAgentConfig): VcMeetingOutputPolicy { + // In-meeting text now sends without per-message approval by default; an + // operator can restore the review gate per-bot via + // meetingConsumer.textOutputPolicy = 'approval' (or 'deny'). + const configured = cfg?.meetingConsumer?.textOutputPolicy; + if (configured === 'approval' || configured === 'deny' || configured === 'allow') return configured; + return 'allow'; } function defaultVcMeetingVoiceOutputPolicy(cfg: VcMeetingAgentConfig): VcMeetingOutputPolicy { - return cfg.realtimeVoice?.enabled === true ? 'approval' : 'deny'; + // Voice stays hard-denied unless realtime voice is enabled for the bot. + // Enabling realtimeVoice is itself the explicit opt-in, so once enabled the + // default matches text: send without per-utterance approval. An operator may + // tighten back to 'approval' or 'deny' via meetingConsumer.voiceOutputPolicy + // (dashboard-editable). + if (!vcMeetingRealtimeVoiceEnabled(cfg)) return 'deny'; + const configured = cfg.meetingConsumer?.voiceOutputPolicy; + if (configured === 'approval' || configured === 'deny' || configured === 'allow') return configured; + return 'allow'; +} + +/** + * 实时语音能力现在**默认开启**:`realtimeVoice.enabled` 未配 = 视为开,只有显式 + * `false` 才关。scope 已 fleet 级预授(实测含 vc:meeting.bot.realtime:write),所以 + * 语音关着只是保守默认、不是权限卡。 + * + * 注意「能力开」不等于「入会即建连」:语音 WebSocket 只在这个 bot 真要发言时 + * (deliverVcMeetingVoiceOutput → ensureVcMeetingRealtimeVoiceSession)按需建立, + * 纯监听 / 从不发言的 bot 不会挂空闲连接。见 maybeStartVcMeetingRealtimeVoice。 + */ +function vcMeetingRealtimeVoiceEnabled(cfg: VcMeetingAgentConfig | undefined): boolean { + return cfg?.realtimeVoice?.enabled !== false; } function vcMeetingOutputReviewTimeoutMs(channel: VcMeetingOutputChannel): number { @@ -2415,38 +2468,31 @@ async function ensureVcMeetingReceiverSession( `registered receiver session is not active or lacks managed side-effect isolation: ${existingSessionId}`, ); } + // Plan B: the meeting agent is an ordinary chat-scope session, so a chat can + // legitimately host several meeting projections in one transcript. The old + // strict "one session ⇔ one exact meeting/member/epoch" identity check no + // longer holds. Only require that the resolved session is the right listener + // chat under the right agent app (verified via the resolver binding above) + // and stamp/refresh the delivery-identity metadata for this member epoch so + // later meeting-directed output (Stage 6) can find it. The marker is pure + // metadata now — it does not affect routing. const ds = [...activeSessions.values()].find(candidate => candidate.session.sessionId === existingSessionId) ?? findActiveBySessionId(existingSessionId); - const identity = ds?.session.vcMeetingReceiver; - if (!identity - || identity.listenerAppId !== request.meeting.listenerAppId - || identity.meetingId !== request.meeting.meetingId - || identity.memberId !== request.member.memberId - || identity.memberEpoch !== request.member.epoch) { - throw new Error('registered receiver session is not dedicated to this meeting member epoch'); + if (ds && ds.chatId === request.outputRoute.chatId) { + stampVcMeetingBinding(ds, request); } return existing; } - // A receiver session is a dedicated conversation. It deliberately shares - // the listener chat only as an output route; its activeSessions key is based - // on sessionId (activeSessionKey), so an ordinary chat-scope session or a - // second meeting/member cannot collapse into the same CLI transcript. + // Plan B core cut: a meeting agent is a normal chat-scope session in its + // listener group, keyed by the ordinary `(chatId, appId)` slot. Plain IM and + // meeting transcripts therefore fold into the SAME session (transcripts are + // delivered by target.sessionId; IM is routed by the chat anchor). Reuse an + // existing chat-scope session at that slot if present (whether it was opened + // by IM or by an earlier meeting), else create a cold `worker:null` session + // shaped exactly like an auto-created IM session so triggerSessionTurn's + // cold-fork path spawns it on first delivery. const chatId = request.outputRoute.chatId; - const matching = [...activeSessions.values()].find((candidate) => { - const identity = candidate.session.vcMeetingReceiver; - return candidate.larkAppId === selfAppId - && identity?.listenerAppId === request.meeting.listenerAppId - && identity.meetingId === request.meeting.meetingId - && identity.memberId === request.member.memberId - && identity.memberEpoch === request.member.epoch; - }); - if (matching) { - const binding = resolveVcMeetingReceiverSession(matching.session.sessionId); - if (!binding) throw new Error('existing dedicated receiver session has an unsupported CLI adapter'); - return binding; - } - const bot = getBot(selfAppId); const isolation = vcMeetingConsumerIsolationForBot(bot.config); if (!isolation.decision.ok) { @@ -2460,61 +2506,96 @@ async function ensureVcMeetingReceiverSession( // whose environment/working dir was never set up for bwrap, and the CLI would // fail to spawn or be unable to work (the "joined but never replies" bug). const receiverSandboxed = isolation.decision.isolated; - const rawWorkingDir = findOncallChat(selfAppId, chatId)?.workingDir - ?? effectiveDefaultWorkingDir(bot.config) - ?? bot.config.workingDir - ?? '~'; - const workingDir = validateWorkingDir(rawWorkingDir, localeForBot(selfAppId)); - if (!workingDir.ok) throw new Error(workingDir.error); - const session = sessionStore.createSession( - chatId, - chatId, - `[Meeting] ${request.meeting.meetingId}`.slice(0, 50), - 'group', - ); - const now = Date.now(); - session.larkAppId = selfAppId; - session.scope = 'chat'; - session.vcMeetingReceiver = { + const key = sessionKey(chatId, selfAppId); + const bound = await withActiveSessionKeyLock(activeSessions, key, () => { + const current = activeSessions.get(key); + if (current) { + // A chat-scope session already occupies this slot. Adopt it as the meeting + // agent by stamping the delivery-identity metadata; do NOT create a second + // universe. The session keeps whatever launch/sandbox decision it already + // froze — we never retroactively weaken or strengthen a live session here. + stampVcMeetingBinding(current, request); + return current; + } + + const rawWorkingDir = findOncallChat(selfAppId, chatId)?.workingDir + ?? effectiveDefaultWorkingDir(bot.config) + ?? bot.config.workingDir + ?? '~'; + const workingDir = validateWorkingDir(rawWorkingDir, localeForBot(selfAppId)); + if (!workingDir.ok) throw new Error(workingDir.error); + + const session = sessionStore.createSession( + chatId, + chatId, + `[Meeting] ${request.meeting.meetingId}`.slice(0, 50), + 'group', + ); + const now = Date.now(); + session.larkAppId = selfAppId; + session.scope = 'chat'; + session.vcMeetingReceiver = { + listenerAppId: request.meeting.listenerAppId, + meetingId: request.meeting.meetingId, + memberId: request.member.memberId, + memberEpoch: request.member.epoch, + }; + session.lastMessageAt = new Date(now).toISOString(); + session.workingDir = workingDir.resolvedPath; + session.cliId = bot.config.cliId; + // Freeze the security-critical launch decision at creation. A later live + // Bot-config edit must neither weaken this session nor make an old + // unisolated session appear eligible retroactively. Under plan B the frozen + // value follows the bot's opt-in, not a hardcoded true. + session.sandbox = receiverSandboxed; + session.sandboxHidePaths = receiverSandboxed ? (bot.config.sandboxHidePaths ?? []) : []; + session.sandboxReadonlyPaths = receiverSandboxed ? (bot.config.sandboxReadonlyPaths ?? []) : []; + session.sandboxNetwork = receiverSandboxed ? (bot.config.sandboxNetwork !== false) : true; + session.backendType = isolation.backendType; + sessionStore.updateSession(session); + + const ds: DaemonSession = { + session, + worker: null, + workerPort: null, + workerToken: null, + larkAppId: selfAppId, + chatId, + chatType: 'group', + scope: 'chat', + spawnedAt: Date.parse(session.createdAt) || now, + cliVersion: getCurrentCliVersion(), + lastMessageAt: now, + hasHistory: false, + workingDir: workingDir.resolvedPath, + }; + activeSessions.set(key, ds); + return ds; + }); + + const binding = resolveVcMeetingReceiverSession(bound.session.sessionId); + if (!binding) throw new Error('meeting agent session has an unsupported CLI adapter'); + return binding; +} + +/** Stamp/refresh the VC meeting delivery-identity metadata onto an ordinary + * chat-scope session (Plan B). This binds the session as the delivery + future + * meeting-output target for this member epoch WITHOUT changing routing: the + * session is still keyed by its normal chat slot. The most-recent meeting/member + * epoch wins when one chat hosts several meetings, matching the transcript + * delivery model (targets pick the session by id, not by this marker). */ +function stampVcMeetingBinding( + ds: DaemonSession, + request: Parameters[0], +): void { + ds.session.vcMeetingReceiver = { listenerAppId: request.meeting.listenerAppId, meetingId: request.meeting.meetingId, memberId: request.member.memberId, memberEpoch: request.member.epoch, }; - session.lastMessageAt = new Date(now).toISOString(); - session.workingDir = workingDir.resolvedPath; - session.cliId = bot.config.cliId; - // Freeze the security-critical launch decision at receiver creation. A - // later live Bot-config edit must neither weaken this session nor make an - // old unisolated receiver appear eligible retroactively. Under plan B the - // frozen value follows the bot's opt-in, not a hardcoded true. - session.sandbox = receiverSandboxed; - session.sandboxHidePaths = receiverSandboxed ? (bot.config.sandboxHidePaths ?? []) : []; - session.sandboxReadonlyPaths = receiverSandboxed ? (bot.config.sandboxReadonlyPaths ?? []) : []; - session.sandboxNetwork = receiverSandboxed ? (bot.config.sandboxNetwork !== false) : true; - session.backendType = isolation.backendType; - sessionStore.updateSession(session); - - const ds: DaemonSession = { - session, - worker: null, - workerPort: null, - workerToken: null, - larkAppId: selfAppId, - chatId, - chatType: 'group', - scope: 'chat', - spawnedAt: Date.parse(session.createdAt) || now, - cliVersion: getCurrentCliVersion(), - lastMessageAt: now, - hasHistory: false, - workingDir: workingDir.resolvedPath, - }; - activeSessions.set(activeSessionKey(ds), ds); - const binding = resolveVcMeetingReceiverSession(session.sessionId); - if (!binding) throw new Error('created receiver session has an unsupported CLI adapter'); - return binding; + sessionStore.updateSession(ds.session); } function vcMeetingDeliveryReceiverDeps(receiverAppId?: string): VcMeetingDeliveryReceiverDeps { @@ -2530,6 +2611,18 @@ function vcMeetingDeliveryReceiverDeps(receiverAppId?: string): VcMeetingDeliver dispatchTurn: (request, context) => { const target = findActiveBySessionId(request.target.sessionId ?? ''); if (target) target.vcMeetingImTurnOrigin = undefined; + // VC transcript delivery turns dispatch straight through triggerSessionTurn, + // which (unlike the ordinary IM path) never calls beginNewTurn — so without + // this the meeting agent's streaming-card lifecycle would never arm and no + // live card would post for a transcript-driven turn. Under Plan B the + // meeting agent is an ordinary chat-scope session whose card should surface + // like any group session (the "看不到流式卡片" report), so arm it for every + // delivery turn. A silent-responseMode turn still suppresses only its final + // output (managedFinalOutputSuppressed), not the live card. + if (target?.session.vcMeetingReceiver && context.stableTurnId) { + const title = target.currentTurnTitle || target.session.title || '会议监听'; + beginNewTurn(target, title, context.stableTurnId); + } return triggerSessionTurn( request, { larkAppId: selfAppId, activeSessions }, @@ -2783,7 +2876,13 @@ async function maybeCatchUpVcMeetingConsumerBeforeTurn( + `agent=${ctx.larkAppId} status=${caughtUp.catchUpStatus} error=${caughtUp.catchUpError ?? '-'}`, ); } - return { anchorOverride: `vc-receiver:${caughtUp.candidate.receiverSessionId}` }; + // Plan B: the meeting agent is an ordinary chat-scope session keyed by the + // normal `(chatId, appId)` slot, so the natural chat anchor already resolves + // it — no `vc-receiver:` anchor override is needed (or correct) anymore. This + // hook now only (a) blocks on ambiguous/undeliverable meeting context and + // (b) stamps vcMeetingImTurnOrigin so an @mention follow-up carries the + // meeting delivery identity for catch-up/echo purposes. + return; } async function pinVcMeetingConsumerChatReplyMode( @@ -3160,7 +3259,14 @@ export async function noteTurnReceived( // message — not a worker status edge — means type-ahead / busy-batched messages // each get their own ✋. `finishTurnReactions` flips every pending ✋ to ✅ when // the worker next goes idle. - if (ds.session.vcMeetingReceiver) return; + // Plan B: a meeting agent is an ordinary chat-scope session that hosts plain + // user turns. A plain user message should get the ✋→✅ progress reaction like + // any card-off session. Only a stamped meeting @mention follow-up stays + // reaction-free (it is meeting-driven and routes through the audited listener + // action, not the ordinary progress-reaction channel). Transcript deliveries + // never reach this inbound-message acceptance point at all. + if (ds.session.vcMeetingReceiver + && resolveVcMeetingImTurnOrigin(ds.session, triggerMessageId) !== undefined) return; // Turn-exact card-off check: the reaction ack belongs to THIS message's turn, // not to whichever turn most recently overwrote currentReplyTarget. if (!streamingCardDisabledFor(ds, triggerMessageId)) return; @@ -6452,7 +6558,67 @@ ipcRoute('POST', '/api/vc-meetings/action-request', async (req, res) => { claimedTurnId: typeof body.originTurnId === 'string' ? body.originTurnId : undefined, claimedDispatchAttempt: claimedAttempt, }); - if (!verified.ok) return jsonRes(res, 403, verified); + // Plan B: the meeting agent is an ordinary chat-scope session that interleaves + // plain user IM turns with meeting deliveries and reaches idle between them. + // The in-memory managedTurnOrigin is cleared at each delivery turn's terminal + // edge, so a request-output the model runs AFTER the delivery turn goes idle + // (or after an intervening IM turn) finds no live origin and would fail + // `origin_unproven` — even though the durable delivery receipt still fully + // authorizes it. Fall back to the DURABLE receipt for a claimed delivery origin + // (turnId + dispatchAttempt): evaluateVcMeetingManagedSend re-derives authority + // from the on-disk receipt exactly as the final-output path does — receipt must + // exist for THIS receiver session, the attempt must match, its status must be + // dispatched/completed, the projection must still be active, and a silent + // delivery is refused. This never authorizes anything the receipt itself + // wouldn't, so it does not weaken the boundary; it only survives the idle gap. + // + // The fallback intentionally does NOT require the live origin to be absent: + // live verification proves origin via the rotating worker capability only, + // and non-sandboxed sessions have no origin-channel transport for that + // capability (managedOriginChannelRequired is darwin-isolate/credential-only + // bwrap), so their live check ALWAYS fails — including while the delivery + // turn is still executing. Gating the fallback on "live origin cleared" + // hard-bricked exactly that case (in-turn speech from a non-sandboxed + // meeting agent). The durable receipt check is the same strength either way, + // so the fallback runs whenever live verification failed. + let effectiveVerified = verified; + if (!verified.ok + && typeof body.originTurnId === 'string' + && body.originTurnId.trim() + && claimedAttempt !== undefined) { + const claimedDeliveryTurnId = body.originTurnId.trim(); + const durable = evaluateVcMeetingManagedSend(config.session.dataDir, { + receiverSessionId, + receiverSession: true, + turnId: claimedDeliveryTurnId, + dispatchAttempt: claimedAttempt, + allowTerminalReceipt: true, + // In-meeting output channel: silent responseMode gates only listener-group + // auto-post, not in-meeting speech (the hub applies capability + + // textOutputPolicy/voiceOutputPolicy). Prove receipt identity/liveness here + // but do not apply the silent veto. + forInMeetingOutput: true, + }); + if (durable.ok && durable.kind === 'listener_thread') { + logger.info( + `[vc-agent] request-output authorized via durable receipt fallback ` + + `(live origin ${ds.managedTurnOrigin ? 'present but unverifiable — no capability transport' : 'cleared at turn terminal'}) ` + + `session=${receiverSessionId.slice(0, 8)} ` + + `turn=${claimedDeliveryTurnId.slice(0, 12)} attempt=${claimedAttempt}`, + ); + effectiveVerified = { + ok: true, + origin: { + receiverSessionId, + turnId: claimedDeliveryTurnId, + dispatchAttempt: claimedAttempt, + currentImTurnId: undefined, + currentImTurnOrigin: undefined, + }, + }; + } + } + if (!effectiveVerified.ok) return jsonRes(res, 403, effectiveVerified); const channel = body.channel === 'text' || body.channel === 'voice' ? body.channel : undefined; const content = sanitizeVcMeetingOutputContent(body.content, 'content'); const reason = sanitizeVcMeetingOutputContent(body.reason, 'reason'); @@ -6464,13 +6630,13 @@ ipcRoute('POST', '/api/vc-meetings/action-request', async (req, res) => { ? body.expectedListenerAppId.trim() : ''; const expectedMeetingId = typeof body.expectedMeetingId === 'string' ? body.expectedMeetingId.trim() : ''; - if (verified.origin.dispatchAttempt === undefined) { - const imOrigin = resolveVcMeetingImTurnOrigin(ds.session, verified.origin.turnId); - if (!verified.origin.turnId - || verified.origin.currentImTurnId !== verified.origin.turnId + if (effectiveVerified.origin.dispatchAttempt === undefined) { + const imOrigin = resolveVcMeetingImTurnOrigin(ds.session, effectiveVerified.origin.turnId); + if (!effectiveVerified.origin.turnId + || effectiveVerified.origin.currentImTurnId !== effectiveVerified.origin.turnId || !imOrigin || imOrigin.receiverSessionId !== receiverSessionId - || imOrigin.larkMessageId !== verified.origin.turnId) { + || imOrigin.larkMessageId !== effectiveVerified.origin.turnId) { return jsonRes(res, 409, { ok: false, errorCode: 'im_turn_origin_mismatch', @@ -6503,14 +6669,14 @@ ipcRoute('POST', '/api/vc-meetings/action-request', async (req, res) => { ); return jsonRes(res, upstream.status, upstream.body); } - if (!verified.origin.turnId) { + if (!effectiveVerified.origin.turnId) { return jsonRes(res, 409, { ok: false, errorCode: 'delivery_origin_mismatch', error: 'durable delivery action has no stable turn id', }); } - const lookup = findVcMeetingDeliveryByKey(config.session.dataDir, verified.origin.turnId, { + const lookup = findVcMeetingDeliveryByKey(config.session.dataDir, effectiveVerified.origin.turnId, { receiverSessionId, }); if (!lookup) { @@ -6544,8 +6710,8 @@ ipcRoute('POST', '/api/vc-meetings/action-request', async (req, res) => { body: JSON.stringify({ agentAppId: ds.larkAppId, receiverSessionId, - stableTurnId: verified.origin.turnId, - dispatchAttempt: verified.origin.dispatchAttempt, + stableTurnId: effectiveVerified.origin.turnId, + dispatchAttempt: effectiveVerified.origin.dispatchAttempt, channel, content, ...(reason ? { reason } : {}), @@ -6704,7 +6870,49 @@ function effectiveVcMeetingAgentConfig(larkAppId: string): VcMeetingAgentConfig // `restoreVcMeetingRuntimeSessionsForBot`, whose call site sits OUTSIDE the // `!cfg.apiOnly` boot block — so a migrated bots.json (normal VC bot flipped to // apiOnly, stale runtime record on disk) can no longer re-spawn lark-cli at boot. - return vcMeetingAgentConfigActive(getBot(larkAppId)?.config); + // + // 这是 daemon 侧唯一一处产出 VcMeetingAgentConfig 的地方,所以角色预设的绑定也在 + // 这里做:没有自己 consumerProfiles 的 bot 继承 fleet 共享目录,且**所有**预设的 + // 执行方一律重绑为 larkAppId 本人——「拉 A 进会却拉 B 进群」在这一层被彻底切断, + // 历史配置里写歪的 agentAppId 也在读路径上被纠正,不需要迁移写盘。 + const cfg = vcMeetingAgentConfigActive(getBot(larkAppId)?.config); + if (!cfg) return cfg; + return withDefaultVcMeetingJoinProfile(larkAppId, bindVcMeetingConsumerCatalogToBot(larkAppId, cfg)); +} + +/** + * 没配 `larkCliProfile` 的 bot,入会身份默认用它**自己的 appId** 作为 lark-cli + * profile 名。 + * + * 背景:入会门禁 {@link ensureVcMeetingJoinProfile} 要求 `larkCliProfile` 非空, + * 否则以 `no_profile` 拒绝入会。而这个字段过去只有 Dashboard 的「配置权限」按钮 + * 会落盘(见 preflightVcMeetingBot),于是 fleet 里绝大多数从没点过那颗按钮的 bot + * 被拉进会一律被拒——正是「拉别的 bot 进会还是不行」。 + * + * 把默认值放在**读路径**而不是要求人工点按钮,是因为这个默认值是纯确定性的: + * - 就是 bot **自己的 appId**,既不是跨 app 身份、也不含任何 secret,app-scoped, + * 不触碰 owner 身份边界; + * - profile 一旦有名字,{@link ensureLarkCliBotProfile} 会用该 bot **自己**保存的 + * appSecret 走 `--app-secret-stdin`(不进 argv、只落 lark-cli 加密库、只在受信 + * daemon 里)自动注册它——PR #392 当年之所以 fail-closed(“no faked profile”), + * 是因为那时没有自动注册、写个名字会 ringing;PR #782 补上自动注册后这个顾虑 + * 已消除。 + * + * 真正需要人在场的是**开放平台权限开通 / 事件订阅**(可能要扫码登录),那部分仍留在 + * 「配置权限」按钮里;权限/secret 真缺时,入会会在后面以 `missing_secret` / + * `add_failed` / scope 校验等**可操作**错误失败,而不是这里这条造出来的 `no_profile`。 + * + * apiOnly bot 在 {@link vcMeetingAgentConfigActive} 已返回 undefined,走不到这里, + * 所以默认只加给结构上真能入会的 bot。纯函数,不改入参、不写盘。 + */ +function withDefaultVcMeetingJoinProfile( + larkAppId: string, + cfg: VcMeetingAgentConfig, +): VcMeetingAgentConfig { + if (cfg.larkCliProfile?.trim()) return cfg; + const appId = larkAppId.trim(); + if (!appId) return cfg; + return { ...cfg, larkCliProfile: appId }; } function configuredVcMeetingListenerChatId(cfg: VcMeetingAgentConfig): string | undefined { @@ -6839,7 +7047,7 @@ function restoreVcMeetingRuntimeSessionsForBot(larkAppId: string, cfg: VcMeeting session.selectedAgentLabel = migratePreparedConsumer ? undefined : record.selectedAgentLabel; session.consumerPaused = migratePreparedConsumer ? false : record.consumerPaused; if (session.consumerMode === 'agent') session.consumerLastInjectedAtMs = undefined; - session.textOutputPolicy = record.textOutputPolicy ?? defaultVcMeetingTextOutputPolicy(); + session.textOutputPolicy = record.textOutputPolicy ?? defaultVcMeetingTextOutputPolicy(cfg); session.voiceOutputPolicy = record.voiceOutputPolicy ?? defaultVcMeetingVoiceOutputPolicy(cfg); session.syncIntervalMs = record.syncIntervalMs; session.consumerSelectionExpiresAt = record.consumerSelectionExpiresAt; @@ -7065,28 +7273,31 @@ async function maybeStartVcMeetingRealtimeVoice( cfg: VcMeetingAgentConfig, ): Promise { const voiceCfg = cfg.realtimeVoice; - if (voiceCfg?.enabled !== true) return; + if (!vcMeetingRealtimeVoiceEnabled(cfg)) return; + // 按需建连:实时语音能力默认开,但入会时**不**急着开语音 WS——真正要发言时 + // deliverVcMeetingVoiceOutput 会按需 ensureVcMeetingRealtimeVoiceSession 建连, + // 纯监听 / 从不发言的 bot 不挂空闲连接。唯一在入会时就预热的情况是显式配了 + // testSpeakOnStartText 的 dogfood 场景(要在入会瞬间说一句自检话)。 + if (!voiceCfg?.testSpeakOnStartText || session.realtimeVoiceTestUtteranceSent) return; try { const voice = await ensureVcMeetingRealtimeVoiceSession(larkAppId, session, cfg); - if (voiceCfg.testSpeakOnStartText && !session.realtimeVoiceTestUtteranceSent) { - session.realtimeVoiceTestUtteranceSent = true; - void (async () => { - let sent = false; - try { - const r = await voice.speak(voiceCfg.testSpeakOnStartText!); - sent = true; - logger.info(`[vc-agent] realtime voice test utterance sent meeting=${session.state.meeting.id} frames=${r.frames} durationMs=${r.durationMs}`); - await delay(DEFAULT_VC_REALTIME_TEST_SPEAK_CLOSE_GRACE_MS); - } catch (err) { - logger.warn(`[vc-agent] realtime voice test utterance failed meeting=${session.state.meeting.id}: ${err instanceof Error ? err.message : String(err)}`); - } finally { - await voice.stop(sent ? 'test-speak-finished' : 'test-speak-failed').catch((err) => { - logger.warn(`[vc-agent] realtime voice test utterance stop failed meeting=${session.state.meeting.id}: ${err instanceof Error ? err.message : String(err)}`); - }); - if (session.realtimeVoice === voice) session.realtimeVoice = undefined; - } - })(); - } + session.realtimeVoiceTestUtteranceSent = true; + void (async () => { + let sent = false; + try { + const r = await voice.speak(voiceCfg.testSpeakOnStartText!); + sent = true; + logger.info(`[vc-agent] realtime voice test utterance sent meeting=${session.state.meeting.id} frames=${r.frames} durationMs=${r.durationMs}`); + await delay(DEFAULT_VC_REALTIME_TEST_SPEAK_CLOSE_GRACE_MS); + } catch (err) { + logger.warn(`[vc-agent] realtime voice test utterance failed meeting=${session.state.meeting.id}: ${err instanceof Error ? err.message : String(err)}`); + } finally { + await voice.stop(sent ? 'test-speak-finished' : 'test-speak-failed').catch((err) => { + logger.warn(`[vc-agent] realtime voice test utterance stop failed meeting=${session.state.meeting.id}: ${err instanceof Error ? err.message : String(err)}`); + }); + if (session.realtimeVoice === voice) session.realtimeVoice = undefined; + } + })(); } catch (err) { await session.realtimeVoice?.stop('start-failed').catch(() => { /* ignore cleanup errors */ }); session.realtimeVoice = undefined; @@ -7099,8 +7310,8 @@ async function ensureVcMeetingRealtimeVoiceSession( session: VcMeetingDaemonSession, cfg: VcMeetingAgentConfig, ): Promise { + if (!vcMeetingRealtimeVoiceEnabled(cfg)) throw new Error('realtime voice is disabled'); const voiceCfg = cfg.realtimeVoice; - if (voiceCfg?.enabled !== true) throw new Error('realtime voice is disabled'); if (session.realtimeVoice && (session.realtimeVoice.status === 'failed' || session.realtimeVoice.status === 'stopped')) { await session.realtimeVoice.stop('rebuild-stale-session').catch((err) => { logger.warn(`[vc-agent] stale realtime voice cleanup failed meeting=${session.state.meeting.id}: ${err instanceof Error ? err.message : String(err)}`); @@ -7117,9 +7328,9 @@ async function ensureVcMeetingRealtimeVoiceSession( protocol: createProtoRealtimeVoiceProtocol(), transport, audioFormat: { - ...(voiceCfg.sampleRate !== undefined ? { sampleRate: voiceCfg.sampleRate } : {}), - ...(voiceCfg.channels !== undefined ? { channels: voiceCfg.channels } : {}), - ...(voiceCfg.frameMs !== undefined ? { frameMs: voiceCfg.frameMs } : {}), + ...(voiceCfg?.sampleRate !== undefined ? { sampleRate: voiceCfg.sampleRate } : {}), + ...(voiceCfg?.channels !== undefined ? { channels: voiceCfg.channels } : {}), + ...(voiceCfg?.frameMs !== undefined ? { frameMs: voiceCfg.frameMs } : {}), }, }); } @@ -7269,7 +7480,7 @@ function getOrCreateVcMeetingDaemonSession( monitoringStarted: false, ...(listenerChatId ? { listenerChatId } : {}), pendingItems: [], - textOutputPolicy: defaultVcMeetingTextOutputPolicy(), + textOutputPolicy: defaultVcMeetingTextOutputPolicy(cfg), voiceOutputPolicy: defaultVcMeetingVoiceOutputPolicy(cfg), pendingOutputRequests: {}, consumerPendingItems: [], @@ -7297,7 +7508,7 @@ function getOrCreateVcMeetingDaemonSession( session.listenerChatId = listenerChatId; session.state.notificationChatId = listenerChatId; } - session.textOutputPolicy ??= defaultVcMeetingTextOutputPolicy(); + session.textOutputPolicy ??= defaultVcMeetingTextOutputPolicy(cfg); session.voiceOutputPolicy ??= defaultVcMeetingVoiceOutputPolicy(cfg); session.pendingOutputRequests ??= {}; session.consumerPendingItems ??= []; @@ -10063,7 +10274,11 @@ function vcMeetingConsumerHasFastSignal( : item.type === 'transcript_received' ? item.speaker : undefined; - if (isInstructionSource(actor) && /[??]/.test(text)) return true; + // Any speech/chat from an instruction source (the authorizing user) is a + // fast signal — not only questions. Waiting out the regular flush tick on + // the operator's own words made the agent feel unresponsive in-meeting; + // other participants still batch on the normal cadence. + if (isInstructionSource(actor)) return true; } return false; } @@ -15895,6 +16110,8 @@ export const __vcMeetingAgentTest = { const cfg = effectiveVcMeetingAgentConfig(larkAppId); if (cfg) restoreVcMeetingRuntimeSessionsForBot(larkAppId, cfg); }, + /** 测试用:读某个 bot 的有效 VC 配置(含共享目录绑定 + larkCliProfile 默认值)。 */ + effectiveConfig: (larkAppId: string) => effectiveVcMeetingAgentConfig(larkAppId), reset: () => { for (const session of vcMeetingSessions.values()) { if (session.flushTimer) clearInterval(session.flushTimer); @@ -20728,26 +20945,15 @@ export async function startDaemon(botIndex?: number): Promise { botConfigs = loadBotConfigs(); cfg = loadBotConfigAtIndex(idx); } - // One-time, lock-protected catalog bootstrap. This runs only after the - // complete bots.json has parsed successfully, and the helper re-reads the - // latest file under its lock before deciding. Explicit [] and legacy agent - // policy are durable opt-outs. Reload the selected config after a write so - // this daemon exposes the seeded selection card immediately on the same boot. + // 这里曾经有一次「给本 bot 播种默认会议角色预设」的启动写盘。已退役:角色预设 + // 改成全 fleet 共享目录 + 读路径内置默认(services/vc-meeting-shared-consumer- + // catalog.ts),没有任何 bot 还需要自己那份 per-bot 拷贝。退役的两个理由: + // 1. 播种出来的 per-bot `consumerProfiles` 会永久遮蔽共享目录——操作者在 + // Dashboard 改共享预设,被播种过的 bot 完全不跟随; + // 2. 那次写盘还会把「另一个 bot」的 appId 焊进预设(旧的换人兜底),正是 + // 「拉 A 进会却把 B 拉进监听群」的源头。 + // 现在启动路径对 VC 预设零写盘,fleet 里几十个 daemon 同时启动也不再有写竞争。 const selectedAppId = cfg.larkAppId; - const profileBootstrap = await bootstrapVcMeetingDefaultConsumerProfile(selectedAppId); - if (profileBootstrap.ok) { - if (profileBootstrap.seeded) { - logger.info( - `[vc-agent] seeded default meeting minutes profile listener=${selectedAppId} ` - + `agent=${profileBootstrap.agentAppId}`, - ); - } - } else if (!profileBootstrap.ok) { - logger.warn( - `[vc-agent] default consumer profile bootstrap skipped: ${profileBootstrap.reason}` - + `${profileBootstrap.error ? ` (${profileBootstrap.error})` : ''}`, - ); - } // A bootstrap failure is not authority to keep an earlier in-memory config. // Every explicit PM2 daemon must prove its same raw slot and App identity // immediately before registerBot, regardless of the bootstrap result. @@ -21541,6 +21747,14 @@ export async function startDaemon(botIndex?: number): Promise { checkRequiredScopes(cfg.larkAppId).catch(err => { logger.debug(`[${cfg.larkAppId}] required-scope check failed: ${err?.message ?? err}`); }); + // Ensure VC meeting events are subscribed so ANY invited bot can receive + // meeting invites (bot-agnostic auto-join). Check-first + best-effort: a + // read-only probe over the cached web session, auto-subscribing only when + // events are missing. Never blocks boot; the fn itself skips VC-inactive + // bots via vcMeetingAgentConfigActive. + ensureVcMeetingEventsSubscribed(cfg.larkAppId).catch(err => { + logger.debug(`[${cfg.larkAppId}] VC event subscription check failed: ${err?.message ?? err}`); + }); } // 主动开工 — 场景①: the bot.added event can't be self-verified via API, and diff --git a/src/dashboard.ts b/src/dashboard.ts index 410c8b7e0..32a73c593 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -175,7 +175,7 @@ import { import { effectiveDefaultWorkingDir, getBot, loadBotConfigs, parseBotConfigsFromText, type BotConfig, type VcMeetingAgentConfig } from './bot-registry.js'; import { addChatToFeedGroup, createFeedGroup, FEED_GROUP_SCOPES, FeedGroupApiError, listFeedGroups } from './dashboard/feed-groups.js'; import { generateAuthUrl, handleCallbackUrl, isCallbackUrl } from './utils/user-token.js'; -import { findEntryIndex, readRawConfig, requireConfigPath, writeRawConfigAtomic } from './services/config-store.js'; +import { findEntryIndex, readRawConfig, requireConfigPath, rmwBotEntry, writeRawConfigAtomic } from './services/config-store.js'; import { emitCodexNotifierOutboxItem, installCodexNotifierHook, @@ -197,16 +197,12 @@ import { handleVcMeetingConsumerProfilesPut, type VcMeetingConsumerProfilesApiDeps, } from './dashboard/vc-consumer-profiles-api.js'; -import { - buildVcMeetingConsumerBootstrapAgents, - seedVcMeetingDefaultConsumerProfile, -} from './services/vc-meeting-consumer-profile-bootstrap.js'; import { evaluateVcMeetingConsumerIsolation } from './services/vc-meeting-consumer-isolation.js'; import { resolvePairedSpawnBackendType } from './core/persistent-backend.js'; import { - readVcMeetingConsumerProfiles, - updateVcMeetingConsumerProfiles, -} from './services/vc-meeting-consumer-profile-store.js'; + readVcMeetingSharedConsumerCatalogSnapshot, + updateVcMeetingSharedConsumerCatalog, +} from './services/vc-meeting-shared-consumer-catalog-store.js'; import { isValidRoleProfileId } from './services/role-profile-store.js'; import { mergeSafeInsightOverviews } from './services/insight/report.js'; import type { SafeInsightOverview } from './services/insight/types.js'; @@ -622,14 +618,6 @@ interface ResolvedDashboardSettings { /** Machine-wide VC meeting listener kill-switch. Default ON. */ vcMeetingAgent: { enabled: boolean; - listenerBotAppId?: string | null; - listenerBotOptions: Array<{ - larkAppId: string; - botName?: string | null; - cliId?: string; - vcMeetingAgentEnabled: boolean; - hasLarkCliProfile: boolean; - }>; /** Detected lark-cli version, or null if not installed. */ larkCliVersion?: string | null; /** True when the installed lark-cli meets the VC bot minimum version. */ @@ -661,43 +649,6 @@ interface ResolvedDashboardSettings { effectiveScheduleTimeZone: string; } -function vcMeetingListenerBotOptions(): ResolvedDashboardSettings['vcMeetingAgent']['listenerBotOptions'] { - try { - const onlineByAppId = new Map(registry.list().map(bot => [bot.larkAppId, bot] as const)); - // Exclude core-only (apiOnly) bots: a VC listener attends real Feishu - // meetings and needs open-platform scopes + a live Lark connection, which a - // no-Feishu bot categorically cannot have. Offering it would let setup - // raw-fetch token/application APIs with its synthetic/empty credentials. - return loadBotConfigs().filter(bot => bot.apiOnly !== true).map(bot => ({ - larkAppId: bot.larkAppId, - botName: bot.displayName ?? onlineByAppId.get(bot.larkAppId)?.botName ?? bot.name ?? null, - cliId: onlineByAppId.get(bot.larkAppId)?.cliId ?? bot.cliId, - vcMeetingAgentEnabled: bot.vcMeetingAgent?.enabled === true, - hasLarkCliProfile: typeof bot.vcMeetingAgent?.larkCliProfile === 'string' && bot.vcMeetingAgent.larkCliProfile.trim().length > 0, - })); - } catch { - return []; - } -} - - -async function validateVcMeetingListenerBotAppId(appId: string): Promise<{ ok: true } | { ok: false; error: string }> { - let bots: BotConfig[]; - try { - bots = loadBotConfigs(); - } catch (err: any) { - return { ok: false, error: `vcMeetingAgent_listenerBot_config_unavailable: ${err?.message ?? err}` }; - } - const bot = bots.find(b => b.larkAppId === appId); - if (!bot) return { ok: false, error: 'vcMeetingAgent_listenerBot_unknown' }; - // Core-only (apiOnly) bots cannot attend Feishu meetings (no Feishu connection, - // no open-platform scopes). Reject at the settings WRITE boundary so a manual - // PUT can't select one and drive syncVcMeetingListenerBotConfig → - // automateOpenPlatformSetup against the open platform with synthetic creds. - if (bot.apiOnly === true) return { ok: false, error: 'vcMeetingAgent_listenerBot_api_only' }; - return { ok: true }; -} - async function validateCodexNotifierTargetBotAppId( appId: string, options: { requireReady?: boolean } = {}, @@ -812,13 +763,36 @@ function refreshLocalVcMeetingAgentConfig(appId: string): void { } } +/** Map appId → persisted Feishu-probed botName from bots-info.json (offline + * bots keep a friendly name in the dashboard). Best-effort; empty on any error. */ +function readPersistedBotNames(): Map { + const out = new Map(); + try { + const fp = join(config.session.dataDir, 'bots-info.json'); + if (!existsSync(fp)) return out; + const entries = JSON.parse(readFileSync(fp, 'utf8')) as Array<{ larkAppId?: string; botName?: string | null }>; + if (!Array.isArray(entries)) return out; + for (const e of entries) { + if (typeof e?.larkAppId === 'string' && typeof e?.botName === 'string' && e.botName.trim()) { + out.set(e.larkAppId, e.botName.trim()); + } + } + } catch { /* best-effort */ } + return out; +} + function vcMeetingConsumerProfilesApiDeps(): VcMeetingConsumerProfilesApiDeps { + // Persisted Feishu-probed names, so an OFFLINE bot still shows its friendly + // name in the agent dropdown instead of falling back to the raw appId. The + // live registry only knows online bots; bots-info.json is written when a bot + // is probed and survives across restarts. Built once per deps construction. + const persistedBotNames = readPersistedBotNames(); return { - readSnapshot: readVcMeetingConsumerProfiles, - updateSnapshot: updateVcMeetingConsumerProfiles, + readCatalog: readVcMeetingSharedConsumerCatalogSnapshot, + updateCatalog: updateVcMeetingSharedConsumerCatalog, loadBotConfigs, effectiveDefaultWorkingDir, - onlineBotName: appId => registry.getByAppId(appId)?.botName, + onlineBotName: appId => registry.getByAppId(appId)?.botName ?? persistedBotNames.get(appId), isOnline: appId => !!registry.getByAppId(appId), adapterReliableTurnTerminal: (cliId, cliPathOverride) => { if (!cliId) return false; @@ -852,6 +826,48 @@ function vcMeetingConsumerProfilesApiDeps(): VcMeetingConsumerProfilesApiDeps { return decision.ok && decision.isolated; }, reloadDaemons: reloadVcMeetingBotConfigOnDaemons, + applyBotOutputPolicy: async (patch) => { + const res = await rmwBotEntry(patch.appId, (entry) => { + const vc = (entry.vcMeetingAgent && typeof entry.vcMeetingAgent === 'object' && !Array.isArray(entry.vcMeetingAgent)) + ? entry.vcMeetingAgent + : {}; + // 「接收会议事件」开关。VC 对每个连着飞书的 bot 默认可用,`enabled: false` + // 才是显式退出——所以打开时删掉这个 key 回到默认,而不是写 `enabled: true`。 + if (patch.vcEnabled) delete vc.enabled; + else vc.enabled = false; + const consumer = (vc.meetingConsumer && typeof vc.meetingConsumer === 'object' && !Array.isArray(vc.meetingConsumer)) + ? vc.meetingConsumer + : {}; + if (patch.textOutputPolicy === null) delete consumer.textOutputPolicy; + else consumer.textOutputPolicy = patch.textOutputPolicy; + if (patch.voiceOutputPolicy === null) delete consumer.voiceOutputPolicy; + else consumer.voiceOutputPolicy = patch.voiceOutputPolicy; + // per-bot 默认角色:null/空 = 跟随全局默认(删 key);否则写 catalogDefaultConsumerId。 + if (patch.catalogDefaultConsumerId === null || patch.catalogDefaultConsumerId === '') { + delete consumer.catalogDefaultConsumerId; + } else { + consumer.catalogDefaultConsumerId = patch.catalogDefaultConsumerId; + } + if (Object.keys(consumer).length > 0) vc.meetingConsumer = consumer; + else delete vc.meetingConsumer; + const rtv = (vc.realtimeVoice && typeof vc.realtimeVoice === 'object' && !Array.isArray(vc.realtimeVoice)) + ? vc.realtimeVoice + : {}; + // 实时语音能力默认开启(未配 = 开)。所以「勾上」= 回到默认,删掉 enabled key + // (避免写死 true,与其它默认字段的处理一致);「取消勾选」= 必须写显式 false + // 才能真正关掉(保留其它 realtimeVoice 设置如采样率)。 + if (patch.realtimeVoiceEnabled) { + delete rtv.enabled; + } else { + rtv.enabled = false; + } + if (Object.keys(rtv).length > 0) vc.realtimeVoice = rtv; + else delete vc.realtimeVoice; + entry.vcMeetingAgent = vc; + return { write: true, result: undefined }; + }); + return res.ok ? { ok: true } : { ok: false, reason: res.reason }; + }, }; } @@ -963,230 +979,159 @@ async function waitForFeishuLoginQr(timeoutMs = 8_000, intervalMs = 200): Promis return null; } -async function syncVcMeetingListenerBotConfig(listenerBotAppId: string | null, previousListenerBotAppId?: string | null): Promise<{ ok: true } | { ok: false; error: string; feishuLoginQr?: string }> { - const nextAppId = listenerBotAppId?.trim() || null; - const prevAppId = previousListenerBotAppId?.trim() || null; - if (!nextAppId && !prevAppId) return { ok: true }; +/** + * 让某个 bot 具备「收会议事件 + 以 bot 身份入会」的开放平台前置条件。 + * + * 这是全局「会议事件接收 Bot」下拉退役后留下的唯一实质工作:那个下拉真正干的事 + * 不是「选一个人来监听」(daemon 侧早已改成谁收到谁处理),而是顺手替被选中的 bot + * 开权限、订事件、装 larkCliProfile。所以下拉删掉,这段保留,改成按 bot 手动触发 + * 的一次性动作(Dashboard 的「配置权限」按钮)——不能在勾选开关时自动跑: + * 「接收会议事件」默认就是开的,压根没有 off→on 的跃迁可挂;也不能在页面加载时 + * 对整个 fleet 跑,47 个 bot 就是 94 次开放平台调用。 + * + * 与旧实现的关键差异:这里**只做前置条件**,绝不再往 meetingConsumer 里塞默认角色。 + * 旧的 seedVcMeetingDefaultConsumerProfile 会把「另一个 bot」的 appId 焊进预设, + * 正是「拉 A 进会却把 B 拉进群」的源头。 + */ +async function preflightVcMeetingBot(appId: string): Promise<{ ok: true } | { ok: false; error: string; feishuLoginQr?: string }> { + const targetAppId = appId?.trim() || null; + if (!targetAppId) return { ok: false, error: 'vcMeetingBot_preflight_missing_appId' }; - // Defense-in-depth: even though the settings validator rejects apiOnly, guard - // the sync entry too so no caller reaches automateOpenPlatformSetup / the - // open-platform raw fetches for a core-only bot. - if (nextAppId) { - try { - const bot = loadBotConfigs().find(b => b.larkAppId === nextAppId); - if (bot?.apiOnly === true) { - return { ok: false, error: 'vcMeetingAgent_listenerBot_api_only' }; - } - } catch { /* fall through to normal errors below */ } + let bots: BotConfig[]; + try { + bots = loadBotConfigs(); + } catch (err: any) { + return { ok: false, error: `vcMeetingBot_preflight_config_unavailable: ${err?.message ?? err}` }; } - - // Require lark-cli >= MIN_LARK_CLI_VERSION_FOR_VC_BOT for VC bot meeting commands - // (vc +meeting-join/events/message-send --as bot). Earlier versions silently reject - // `--as bot` with "this command only supports: user", so the listener bot can - // never actually join a meeting. - if (nextAppId) { - const larkCli = checkLarkCliVersion(); - if (!larkCli) { - return { ok: false, error: 'vcMeetingAgent_listenerBot_larkCli_not_found: 未检测到 lark-cli,请先安装 `npm i -g @larksuite/cli`' }; - } - if (!larkCli.meetsVcBotRequirement) { - return { - ok: false, - error: `vcMeetingAgent_listenerBot_larkCli_too_old: 当前 lark-cli ${larkCli.version} 不支持 VC bot 入会,需要 >= ${MIN_LARK_CLI_VERSION_FOR_VC_BOT}。请运行 \`npm i -g @larksuite/cli@latest\` 升级`, - }; - } + const bot = bots.find(b => b.larkAppId === targetAppId); + if (!bot) return { ok: false, error: 'vcMeetingBot_preflight_bot_not_found' }; + // apiOnly bot 结构上就收不到飞书事件,别让它把 automateOpenPlatformSetup / + // 开放平台裸 fetch 跑起来(写边界拦住,手搓 POST 也进不去)。 + if (bot.apiOnly === true) return { ok: false, error: 'vcMeetingBot_preflight_api_only' }; + + // VC bot 入会命令(vc +meeting-join/events/message-send --as bot)要求 + // lark-cli >= MIN_LARK_CLI_VERSION_FOR_VC_BOT;更老的版本会以 + // "this command only supports: user" 静默拒绝 `--as bot`。 + const larkCli = checkLarkCliVersion(); + if (!larkCli) { + return { ok: false, error: 'vcMeetingBot_preflight_larkCli_not_found: 未检测到 lark-cli,请先安装 `npm i -g @larksuite/cli`' }; + } + if (!larkCli.meetsVcBotRequirement) { + return { + ok: false, + error: `vcMeetingBot_preflight_larkCli_too_old: 当前 lark-cli ${larkCli.version} 不支持 VC bot 入会,需要 >= ${MIN_LARK_CLI_VERSION_FOR_VC_BOT}。请运行 \`npm i -g @larksuite/cli@latest\` 升级`, + }; } - // Best-effort auto-import VC meeting scopes via Open Platform automation. - // Run BEFORE writing bots.json so that hard failures (missing session, needs QR) - // don't leave per-bot vcMeetingAgent in a half-configured state. - // For `brand: 'lark'` bots the open-platform automation only supports feishu.cn; - // skip it silently and let the user configure manually. - if (nextAppId) { - const bots = loadBotConfigs(); - const bot = bots.find(b => b.larkAppId === nextAppId); - const brand = bot?.brand === 'lark' ? 'lark' : 'feishu'; - if (brand === 'lark') { - logger.info(`[vc-agent] skipping open-platform automation for lark-brand bot ${nextAppId} (feishu.cn only)`); - // For lark brand, still validate that required scopes exist before saving - if (bot) { + // 开放平台自动化只支持 feishu.cn;`brand: 'lark'` 的 bot 跳过自动化,但仍然校验 + // 权限是否已具备,免得报「配置好了」其实收不到事件。 + const brand = bot.brand === 'lark' ? 'lark' : 'feishu'; + if (brand === 'lark') { + logger.info(`[vc-agent] skipping open-platform automation for lark-brand bot ${targetAppId} (feishu.cn only)`); + const scopeCheck = await validateVcMeetingScopesForBot(bot); + if (!scopeCheck.ok) { + return { ok: false, error: `vcMeetingBot_preflight_missing_scopes: ${scopeCheck.error}` }; + } + } else { + try { + const result = await automateOpenPlatformSetup({ + appId: targetAppId, + brand, + maxWaitMs: 5_000, + onStatus: (msg) => logger.info(`[vc-agent] scope auto-import: ${msg}`), + }); + if (result.ok) { + logger.info(`[vc-agent] auto-imported ${result.scopeCount} scopes, subscribed ${result.subscribedEventCount} events for bot ${targetAppId}`); + if (result.scopeWarning) logger.warn(`[vc-agent] scope import warning: ${result.scopeWarning}`); + if (result.eventWarning) logger.warn(`[vc-agent] event subscription warning: ${result.eventWarning}`); + // 自动化「成功」不等于权限真开了:internal scope/update 可能静默跳过本租户 + // 不可用的 scope。必须回读一次,否则会给用户一个「已配置」的假绿灯。 const scopeCheck = await validateVcMeetingScopesForBot(bot); if (!scopeCheck.ok) { - return { ok: false, error: `vcMeetingAgent_listenerBot_missing_scopes: ${scopeCheck.error}` }; + return { + ok: false, + error: `vcMeetingBot_preflight_missing_scopes_after_auto: ${scopeCheck.error}。请到开放平台手动开通 VC 会议权限后重试。`, + }; } - } - } else { - try { - const result = await automateOpenPlatformSetup({ - appId: nextAppId, - brand, - maxWaitMs: 5_000, - onStatus: (msg) => logger.info(`[vc-agent] scope auto-import: ${msg}`), - }); - if (result.ok) { - logger.info(`[vc-agent] auto-imported ${result.scopeCount} scopes, subscribed ${result.subscribedEventCount} events for listener bot ${nextAppId}`); - if (result.scopeWarning) logger.warn(`[vc-agent] scope import warning: ${result.scopeWarning}`); - if (result.eventWarning) logger.warn(`[vc-agent] event subscription warning: ${result.eventWarning}`); - // Post-validation: verify VC meeting scopes are actually granted after automation. - // The internal scope/update may silently skip some scopes (e.g. not available - // in this tenant). Without this check, a bot without VC scopes could be saved - // as global listener and silently drop all meeting events. - if (bot) { - const scopeCheck = await validateVcMeetingScopesForBot(bot); - if (!scopeCheck.ok) { - return { - ok: false, - error: `vcMeetingAgent_listenerBot_missing_scopes_after_auto: ${scopeCheck.error}。请到开放平台手动开通 VC 会议权限后重试。`, - }; - } - } - // Event subscription is also critical: listener 缺任一 VC 事件都收不到 - // 会议邀请(missingVcEvents 判定,总 count 无法区分缺的是不是 VC)。 - const eventGateError = vcListenerEventGateError(result); - if (eventGateError) { - return { - ok: false, - error: `vcMeetingAgent_listenerBot_event_subscribe_failed: ${eventGateError},bot 无法接收会议邀请事件。请到开放平台手动订阅 VC 会议事件后重试。`, - }; - } - } else { - const reason = result.reason; - // Session/login-related failures are hard failures — return QR so user can re-login. - // Without a valid Open Platform session, scope/event auto-import is impossible. - if ( - reason === 'missing_session' - || reason === 'invalid_session' - || reason === 'missing_csrf' - || reason === 'qr_expired' - || reason === 'timeout' - || reason === 'login_failed' - ) { - feishuLogin.start(); - // feishuLogin.start() returns immediately with status='starting'; the QR - // code is set asynchronously in onQrCode. Wait briefly for it to be ready - // so the frontend can display it inline instead of showing an error without - // a scan entry. - const qrDataUrl = await waitForFeishuLoginQr(); - const hint = '请用飞书扫码完成开放平台登录,登录后重新选择监听 bot 即可自动配置权限'; - return { - ok: false, - error: `vcMeetingAgent_listenerBot_scope_auto_import_failed: ${reason}: ${hint}`, - feishuLoginQr: qrDataUrl ?? undefined, - }; - } - // Non-login failures (network, api_error, etc.) are best-effort — don't - // block the save. The user can fix scopes manually in the console. - logger.warn(`[vc-agent] open-platform automation failed for ${nextAppId}: ${reason}: ${result.message}`); - // Even on non-login automation failure, verify scopes before saving — - // if the bot genuinely lacks VC permissions, don't silently make it listener. - if (bot) { - const scopeCheck = await validateVcMeetingScopesForBot(bot); - if (!scopeCheck.ok) { - return { - ok: false, - error: `vcMeetingAgent_listenerBot_missing_scopes: ${scopeCheck.error}。自动化配置失败(${reason})且权限未满足,请手动开通后重试。`, - }; - } - } - // Also check event subscription status — automation 走到订阅阶段时 - // missingVcEvents 会带回来;listener 缺任一 VC 事件都不能保存。 - const eventGateError = vcListenerEventGateError(result); - if (eventGateError) { - return { - ok: false, - error: `vcMeetingAgent_listenerBot_event_subscribe_failed: ${eventGateError},bot 无法接收会议邀请事件。自动化配置失败(${reason}),请手动订阅 VC 会议事件后重试。`, - }; - } + // 事件订阅同样关键:缺任一 VC 事件都收不到会议邀请(用 missingVcEvents 判定, + // 总 count 无法区分缺的是不是 VC)。 + const eventGateError = vcListenerEventGateError(result); + if (eventGateError) { + return { + ok: false, + error: `vcMeetingBot_preflight_event_subscribe_failed: ${eventGateError},bot 无法接收会议邀请事件。请到开放平台手动订阅 VC 会议事件后重试。`, + }; + } + } else { + const reason = result.reason; + // 登录/会话类失败是硬失败:没有有效的开放平台会话就无从自动配置,直接把 + // 扫码二维码回给前端。 + if ( + reason === 'missing_session' + || reason === 'invalid_session' + || reason === 'missing_csrf' + || reason === 'qr_expired' + || reason === 'timeout' + || reason === 'login_failed' + ) { + feishuLogin.start(); + // start() 立刻返回 status='starting',二维码是在 onQrCode 里异步塞进去的; + // 稍等一下拿到再回,前端才能直接内联展示而不是只给一句错误。 + const qrDataUrl = await waitForFeishuLoginQr(); + const hint = '请用飞书扫码完成开放平台登录,登录后重新点「配置权限」即可自动开通'; + return { + ok: false, + error: `vcMeetingBot_preflight_scope_auto_import_failed: ${reason}: ${hint}`, + feishuLoginQr: qrDataUrl ?? undefined, + }; + } + // 非登录类失败(网络、api_error 等)是 best-effort,不因此判死;但权限与事件 + // 订阅仍然要回读确认,否则等于谎报配置成功。 + logger.warn(`[vc-agent] open-platform automation failed for ${targetAppId}: ${reason}: ${result.message}`); + const scopeCheck = await validateVcMeetingScopesForBot(bot); + if (!scopeCheck.ok) { + return { + ok: false, + error: `vcMeetingBot_preflight_missing_scopes: ${scopeCheck.error}。自动化配置失败(${reason})且权限未满足,请手动开通后重试。`, + }; + } + const eventGateError = vcListenerEventGateError(result); + if (eventGateError) { + return { + ok: false, + error: `vcMeetingBot_preflight_event_subscribe_failed: ${eventGateError},bot 无法接收会议邀请事件。自动化配置失败(${reason}),请手动订阅 VC 会议事件后重试。`, + }; } - } catch (err: any) { - logger.warn(`[vc-agent] open-platform automation error for ${nextAppId}: ${err?.message ?? err}`); } + } catch (err: any) { + logger.warn(`[vc-agent] open-platform automation error for ${targetAppId}: ${err?.message ?? err}`); } } - const changedAppIds = new Set(); + // 唯一的落盘:补一个默认 larkCliProfile。既不写 enabled(默认就是接收),也不碰 + // meetingConsumer——角色预设归 fleet 共享目录管,这里不产生任何 per-bot 预设。 + let changed = false; try { const path = requireConfigPath(); await withFileLock(path, async () => { const raw = await readRawConfig(path); - let changed = false; - - if (nextAppId) { - const idx = findEntryIndex(raw, nextAppId); - if (idx < 0) throw new Error('bot_not_in_config'); - const entry = raw[idx] as Record; - const next = normalizeVcMeetingAgentRecord(entry.vcMeetingAgent); - let entryChanged = false; - const firstEnable = next.enabled !== true; - if (firstEnable) { - next.enabled = true; - next.dashboardManagedListener = true; - entryChanged = true; - } - if (!next.larkCliProfile) { - next.larkCliProfile = nextAppId; - entryChanged = true; - } - const mc = next.meetingConsumer; - const mcRec = mc && typeof mc === 'object' && !Array.isArray(mc) - ? { ...(mc as Record) } - : {}; - // Selecting a global listener is the Dashboard's explicit opt-in to the - // complete meeting pipeline. It intentionally re-enables the listener's - // consumer surface; profile/default ownership is still preserved by the - // own-property gates in seedVcMeetingDefaultConsumerProfile below. - if (mcRec.enabled !== true) { - mcRec.enabled = true; - entryChanged = true; - } - if (seedVcMeetingDefaultConsumerProfile( - mcRec, - nextAppId, - // Resolve against the latest locked bots.json snapshot, not a stale - // pre-lock load. This also makes fallback selection independent of - // the order in which bot entries happen to be stored. - buildVcMeetingConsumerBootstrapAgents( - parseBotConfigsFromText(JSON.stringify(raw)), - ), - )) { - entryChanged = true; - } - next.meetingConsumer = mcRec; - if (entryChanged) { - compactVcMeetingAgentEntry(entry, next); - changed = true; - changedAppIds.add(nextAppId); - } - } - - if (prevAppId && prevAppId !== nextAppId) { - const idx = findEntryIndex(raw, prevAppId); - if (idx >= 0) { - const entry = raw[idx] as Record; - const next = normalizeVcMeetingAgentRecord(entry.vcMeetingAgent); - if (next.dashboardManagedListener === true) { - delete next.dashboardManagedListener; - if (next.enabled === true) delete next.enabled; - compactVcMeetingAgentEntry(entry, next); - changed = true; - changedAppIds.add(prevAppId); - } - } - } - - if (changed) { - // Validate the complete post-mutation file before replacing bots.json. - // Keep this path symmetric with daemon bootstrap so a future generated - // default cannot make the Dashboard persist an invalid registry. - parseBotConfigsFromText(JSON.stringify(raw)); - await writeRawConfigAtomic(path, raw); - } + const idx = findEntryIndex(raw, targetAppId); + if (idx < 0) throw new Error('bot_not_in_config'); + const entry = raw[idx] as Record; + const next = normalizeVcMeetingAgentRecord(entry.vcMeetingAgent); + if (next.larkCliProfile) return; + next.larkCliProfile = targetAppId; + compactVcMeetingAgentEntry(entry, next); + // 落盘前整份校验,和 daemon bootstrap 保持对称,避免 Dashboard 写出非法 registry。 + parseBotConfigsFromText(JSON.stringify(raw)); + await writeRawConfigAtomic(path, raw); + changed = true; }); } catch (err: any) { - return { ok: false, error: `vcMeetingAgent_listenerBot_config_write_failed: ${err?.message ?? err}` }; + return { ok: false, error: `vcMeetingBot_preflight_config_write_failed: ${err?.message ?? err}` }; } - if (changedAppIds.size > 0) await reloadVcMeetingBotConfigOnDaemons([...changedAppIds]); + if (changed) await reloadVcMeetingBotConfigOnDaemons([targetAppId]); return { ok: true }; } @@ -1240,8 +1185,6 @@ function resolveDashboardSettings(): ResolvedDashboardSettings { noVisibleOutputHint: dashboard.noVisibleOutputHint === true, // default OFF; opt-in anti-resend guidance vcMeetingAgent: { enabled: global.vcMeetingAgent?.enabled !== false, - listenerBotAppId: global.vcMeetingAgent?.listenerBotAppId ?? null, - listenerBotOptions: vcMeetingListenerBotOptions(), larkCliVersion: larkCli?.version ?? null, larkCliMeetsRequirement: larkCli?.meetsVcBotRequirement ?? false, larkCliMinVersion: MIN_LARK_CLI_VERSION_FOR_VC_BOT, @@ -1267,8 +1210,6 @@ async function reloadLocaleOnAllDaemons(): Promise { )); } const settingsWriteApplierDeps = defaultSettingsWriteApplierDeps(resolveDashboardSettings, reloadLocaleOnAllDaemons); -settingsWriteApplierDeps.syncVcMeetingListenerBotConfig = syncVcMeetingListenerBotConfig; -settingsWriteApplierDeps.validateVcMeetingListenerBotAppId = validateVcMeetingListenerBotAppId; settingsWriteApplierDeps.validateCodexNotifierTargetBotAppId = validateCodexNotifierTargetBotAppId; settingsWriteApplierDeps.validateHostOverloadAlertTargetBotAppId = validateHostOverloadAlertTargetBotAppId; @@ -4691,10 +4632,7 @@ const server = createServer(async (req, res) => { // ─── 会议角色预设(私有 API:不在 PUBLIC_READ_PATHS,未认证已被 401) ─── if (url.pathname === '/api/vc-meeting/consumer-profiles') { if (req.method === 'GET') { - const out = await handleVcMeetingConsumerProfilesGet( - url.searchParams.get('listenerBotAppId') ?? '', - vcMeetingConsumerProfilesApiDeps(), - ); + const out = await handleVcMeetingConsumerProfilesGet(vcMeetingConsumerProfilesApiDeps()); return jsonRes(res, out.status, out.body); } if (req.method === 'PUT') { @@ -4710,6 +4648,27 @@ const server = createServer(async (req, res) => { return jsonRes(res, 405, { ok: false, error: 'method_not_allowed' }); } + // 按 bot 手动触发的开放平台前置配置(开权限 + 订 VC 事件 + 补 larkCliProfile)。 + // 私有 API,同样不在 PUBLIC_READ_PATHS。做成显式动作而不是随勾选自动跑: + // 「接收会议事件」默认就是开的,没有 off→on 跃迁可挂;页面加载时对整个 fleet + // 跑一遍则是几十上百次开放平台调用。 + if (url.pathname === '/api/vc-meeting/bot-preflight') { + if (req.method !== 'POST') return jsonRes(res, 405, { ok: false, error: 'method_not_allowed' }); + let parsed: unknown; + try { + parsed = await readJsonBody(req); + } catch { + return jsonRes(res, 400, { ok: false, error: 'bad_json' }); + } + const appId = (parsed as { appId?: unknown } | null)?.appId; + if (typeof appId !== 'string' || !appId.trim()) { + return jsonRes(res, 400, { ok: false, error: 'missing_appId' }); + } + const out = await preflightVcMeetingBot(appId); + if (out.ok) return jsonRes(res, 200, { ok: true }); + return jsonRes(res, 400, { ok: false, error: out.error, feishuLoginQr: out.feishuLoginQr }); + } + if (req.method === 'GET' && url.pathname === '/api/role-profiles') { type RoleProfileAggregate = { profileId: string; diff --git a/src/dashboard/settings-write-applier.ts b/src/dashboard/settings-write-applier.ts index 8b8ef700d..1199ee4de 100644 --- a/src/dashboard/settings-write-applier.ts +++ b/src/dashboard/settings-write-applier.ts @@ -93,14 +93,6 @@ export interface ResolvedDashboardSettingsView { noVisibleOutputHint: boolean; vcMeetingAgent: { enabled: boolean; - listenerBotAppId?: string | null; - listenerBotOptions?: Array<{ - larkAppId: string; - botName?: string | null; - cliId?: string; - vcMeetingAgentEnabled?: boolean; - hasLarkCliProfile?: boolean; - }>; larkCliVersion?: string | null; larkCliMeetsRequirement?: boolean; larkCliMinVersion?: string; @@ -152,10 +144,6 @@ export interface SettingsWriteApplierDeps { isLocale: (v: unknown) => v is 'zh' | 'en'; /** Fan out locale reload to all online daemons. */ reloadLocaleOnAllDaemons?: () => Promise; - /** Validate a global VC listener bot selection before mutating bot/global config. */ - validateVcMeetingListenerBotAppId?: (appId: string) => Promise<{ ok: true } | { ok: false; error: string }>; - /** Sync per-bot meeting-listener config after validation passes or when clearing the selection. */ - syncVcMeetingListenerBotConfig?: (listenerBotAppId: string | null, previousListenerBotAppId?: string | null) => Promise<{ ok: true } | { ok: false; error: string; feishuLoginQr?: string }>; /** 校验通知 Bot;保存关闭态配置时只校验静态配置,启用时再要求 daemon 与收件人就绪。 */ validateCodexNotifierTargetBotAppId?: ( appId: string, @@ -247,7 +235,6 @@ export type ApplySettingsWriteError = | 'invalid_remoteAccess' | 'invalid_vcMeetingAgent' | 'invalid_vcMeetingAgent_enabled' - | 'invalid_vcMeetingAgent_listenerBotAppId' | 'invalid_scheduleTimeZone' | 'invalid_whiteboard' | 'invalid_whiteboard_enabled' @@ -569,29 +556,12 @@ export async function applySettingsWrite( } next.enabled = vc.enabled; } - if ('listenerBotAppId' in vc) { - if (vc.listenerBotAppId === null || vc.listenerBotAppId === '') { - if (deps.syncVcMeetingListenerBotConfig) { - const synced = await deps.syncVcMeetingListenerBotConfig(null, currentVcMeetingAgent.listenerBotAppId ?? null); - if (!synced.ok) return { ok: false, error: synced.error, feishuLoginQr: (synced as any).feishuLoginQr }; - } - delete next.listenerBotAppId; - } else if (typeof vc.listenerBotAppId === 'string' && vc.listenerBotAppId.trim()) { - const listenerBotAppId = vc.listenerBotAppId.trim(); - if (deps.validateVcMeetingListenerBotAppId) { - const validation = await deps.validateVcMeetingListenerBotAppId(listenerBotAppId); - if (!validation.ok) return { ok: false, error: validation.error }; - } - if (deps.syncVcMeetingListenerBotConfig) { - const synced = await deps.syncVcMeetingListenerBotConfig(listenerBotAppId, currentVcMeetingAgent.listenerBotAppId ?? null); - if (!synced.ok) return { ok: false, error: synced.error, feishuLoginQr: (synced as any).feishuLoginQr }; - } - next.listenerBotAppId = listenerBotAppId; - } else { - return { ok: false, error: 'invalid_vcMeetingAgent_listenerBotAppId' }; - } - } - if (!('enabled' in vc) && !('listenerBotAppId' in vc)) { + // 全局「会议事件接收 Bot」已退役(daemon 侧 2026-08 起忽略该 pin,见 + // vcMeetingAgentGlobalListenerAppId):每个 VC-active 的 bot 处理自己收到的 + // 会议事件,接不接收改由 bots.json 的 per-bot `vcMeetingAgent.enabled` 控制。 + // 这里显式擦掉历史残留,避免配置里留一个谁都不读的字段误导人。 + if (next.listenerBotAppId !== undefined) delete next.listenerBotAppId; + if (!('enabled' in vc)) { return { ok: false, error: 'invalid_vcMeetingAgent_enabled' }; } deps.mergeGlobalConfig({ vcMeetingAgent: next }); diff --git a/src/dashboard/vc-consumer-profiles-api.ts b/src/dashboard/vc-consumer-profiles-api.ts index d1902cf45..7024f8999 100644 --- a/src/dashboard/vc-consumer-profiles-api.ts +++ b/src/dashboard/vc-consumer-profiles-api.ts @@ -1,10 +1,19 @@ /** * Dashboard 私有 API:「会议角色预设」(VC meeting consumer profiles)。 * + * 2026-08 起预设目录是**全 fleet 共享**的一份(`~/.botmux/config.json` 的 + * `vcMeetingAgent.consumerCatalog`),不再按 bot 分开配置: + * + * - 用户不用再手选「会议事件接收 Bot」——每个开着 VC 的 bot 都处理自己收到的 + * 会议事件,被拉进会的是谁就由谁执行; + * - 预设条目**不带** `agentAppId`。执行方在读路径合并时才绑定为收到事件的那个 + * bot(services/vc-meeting-shared-consumer-catalog.ts),所以「拉 A 进会却把 + * B 拉进监听群」在数据模型层面就不可表达。 + * * 职责边界:本层只做 用户 DTO ↔ canonical 配置 的映射与 HTTP 语义包装; - * 配置 RMW / revision 乐观并发 / 字段校验的权威在 - * `services/vc-meeting-consumer-profile-store.ts`(锁内复核),运行时冲突 - * 裁决的权威在 bot-registry resolver——Dashboard 校验只是提前反馈。 + * revision 乐观并发 / 字段校验的权威在 + * `services/vc-meeting-shared-consumer-catalog-store.ts`,运行时冲突裁决的权威 + * 在 bot-registry resolver——Dashboard 校验只是提前反馈。 * * permissionPreset 是纯 UI 概念,不持久化:保存时映射成 canonical * capabilities/ownedSinks 原语;`custom` 只允许复用同 id 既有 policy, @@ -17,12 +26,13 @@ import type { VcMeetingConsumerResponseMode, VcMeetingListenerOutputPlacement, } from '../types.js'; +import type { VcMeetingSharedConsumerProfile } from '../global-config.js'; import type { VcMeetingActivityType } from '../vc-agent/types.js'; import type { - UpdateVcMeetingConsumerProfilesResult, - VcMeetingConsumerProfileFieldError, - VcMeetingConsumerProfilesSnapshot, -} from '../services/vc-meeting-consumer-profile-store.js'; + UpdateVcMeetingSharedConsumerCatalogResult, + VcMeetingSharedConsumerCatalogFieldError, + VcMeetingSharedConsumerCatalogSnapshot, +} from '../services/vc-meeting-shared-consumer-catalog-store.js'; import { VC_MEETING_CONSUMER_PROFILE_TEMPLATE_CATALOG, type VcMeetingConsumerProfileTemplateCatalog, @@ -33,10 +43,12 @@ export type VcMeetingPermissionPreset = | VcMeetingTemplatePermissionPreset | 'custom'; +/** 字段级错误的形状与 per-bot 时代一致,路径前缀也保持 `profiles[i].*`。 */ +export type VcMeetingConsumerProfileFieldError = VcMeetingSharedConsumerCatalogFieldError; + export interface VcMeetingConsumerProfileDto { id: string; label?: string; - agentAppId: string; instructions?: string; activityTypes?: string[]; responseMode: VcMeetingConsumerResponseMode; @@ -58,28 +70,61 @@ export interface VcMeetingAgentOptionDto { /** Is the managed sandbox boundary actually in force? false ⇒ the bot's Lark * credential is exposed to untrusted meeting input (informed opt-out). */ sandboxIsolated: boolean; + /** 这个 bot 是否接收会议事件(bots.json vcMeetingAgent.enabled)。缺省视为 + * 开启——VC 对每个连着飞书的 bot 默认可用,`enabled: false` 是显式退出。 */ + vcEnabled: boolean; + /** apiOnly(无飞书连接)的 bot 结构上不可能收会议事件,UI 需要禁用它的开关。 */ + vcEligible: boolean; + /** Configured per-bot in-meeting output policies (bots.json + * vcMeetingAgent.meetingConsumer.*). null = unset → daemon default. */ + textOutputPolicy: VcMeetingOutputPolicyValue | null; + voiceOutputPolicy: VcMeetingOutputPolicyValue | null; + /** vcMeetingAgent.realtimeVoice.enabled — hard gate for in-meeting voice. */ + realtimeVoiceEnabled: boolean; + /** per-bot 从共享目录挑的默认角色 id(vcMeetingAgent.meetingConsumer. + * catalogDefaultConsumerId)。null = 未挑 → 跟随共享目录全局默认。 */ + catalogDefaultConsumerId: string | null; + /** Effective values after daemon defaults (kept in sync with + * defaultVcMeetingTextOutputPolicy / defaultVcMeetingVoiceOutputPolicy). */ + effectiveTextOutputPolicy: VcMeetingOutputPolicyValue; + effectiveVoiceOutputPolicy: VcMeetingOutputPolicyValue; +} + +export type VcMeetingOutputPolicyValue = 'allow' | 'approval' | 'deny'; + +export interface VcMeetingBotOutputPolicyPatch { + appId: string; + /** 会议事件接收开关(vcMeetingAgent.enabled)。 */ + vcEnabled: boolean; + /** null clears the override back to the daemon default. */ + textOutputPolicy: VcMeetingOutputPolicyValue | null; + voiceOutputPolicy: VcMeetingOutputPolicyValue | null; + realtimeVoiceEnabled: boolean; + /** per-bot 从共享目录挑的默认角色 id。null(或空串)= 跟随全局默认。 + * 写到 vcMeetingAgent.meetingConsumer.catalogDefaultConsumerId。 */ + catalogDefaultConsumerId: string | null; } export interface VcMeetingConsumerProfilesGetBody { ok: true; - listenerBotAppId: string; revision: string; - catalogState: VcMeetingConsumerProfilesSnapshot['catalogState']; + catalogState: VcMeetingSharedConsumerCatalogSnapshot['catalogState']; defaultMode: 'listenOnly' | 'agents'; defaultConsumerIds: string[]; profiles: VcMeetingConsumerProfileDto[]; agentOptions: VcMeetingAgentOptionDto[]; /** Versioned, read-only templates. Applying one creates a detached editable profile. */ templateCatalog: VcMeetingConsumerProfileTemplateCatalog; - migrationOffer?: VcMeetingConsumerProfilesSnapshot['migrationOffer']; } export interface VcMeetingConsumerProfilesPutRequest { - listenerBotAppId: string; expectedRevision: string; defaultMode: 'listenOnly' | 'agents'; defaultConsumerIds: string[]; profiles: VcMeetingConsumerProfileDto[]; + /** Optional per-bot patches applied to bots.json via the locked + * read-modify-write path after the shared catalog update succeeds. */ + botOutputPolicies?: VcMeetingBotOutputPolicyPatch[]; } export type VcMeetingConsumerProfilesApiResult = @@ -91,16 +136,14 @@ export type VcMeetingConsumerProfilesApiResult = } }; export interface VcMeetingConsumerProfilesApiDeps { - readSnapshot(listenerBotAppId: string): Promise; - updateSnapshot( - listenerBotAppId: string, - input: { - expectedRevision: string; - defaultMode: 'listenOnly' | 'agents'; - defaultConsumerIds: string[]; - profiles: VcMeetingConsumerProfileConfig[]; - }, - ): Promise; + /** 读全局共享目录。同步实现也可以——签名允许两者。 */ + readCatalog(): VcMeetingSharedConsumerCatalogSnapshot | Promise; + updateCatalog(input: { + expectedRevision: string; + defaultMode: 'listenOnly' | 'agents'; + defaultConsumerIds: string[]; + profiles: VcMeetingSharedConsumerProfile[]; + }): UpdateVcMeetingSharedConsumerCatalogResult | Promise; loadBotConfigs(): BotConfig[]; effectiveDefaultWorkingDir(cfg: BotConfig): string | undefined; /** Online DaemonInfo botName lookup; undefined when the daemon is offline. */ @@ -109,8 +152,11 @@ export interface VcMeetingConsumerProfilesApiDeps { adapterReliableTurnTerminal(cliId: string | undefined, cliPathOverride?: string): boolean; managedSideEffectEligible(bot: BotConfig): boolean; sandboxIsolated(bot: BotConfig): boolean; - /** Called after a successful PUT so the live daemon reloads the new catalog. */ + /** Called after a successful PUT so the live daemon reloads changed bots.json. */ reloadDaemons(appIds: string[]): Promise; + /** Locked read-modify-write of one bot's VC switches in bots.json + * (vcMeetingAgent.enabled + meetingConsumer.* + realtimeVoice.enabled). */ + applyBotOutputPolicy(patch: VcMeetingBotOutputPolicyPatch): Promise<{ ok: boolean; reason?: string }>; } const VC_MEETING_OUTPUT_CAPABILITY = 'meeting.output.request'; @@ -176,12 +222,11 @@ export function deriveVcMeetingPermissionPreset( } export function vcMeetingConsumerProfileToDto( - profile: VcMeetingConsumerProfileConfig, + profile: VcMeetingSharedConsumerProfile, ): VcMeetingConsumerProfileDto { return { id: profile.id, ...(profile.label ? { label: profile.label } : {}), - agentAppId: profile.agentAppId, ...(profile.instructions ? { instructions: profile.instructions } : {}), ...(profile.filter?.activityTypes?.length ? { activityTypes: [...profile.filter.activityTypes] } @@ -193,7 +238,7 @@ export function vcMeetingConsumerProfileToDto( } type DtoValidation = - | { ok: true; profiles: VcMeetingConsumerProfileConfig[] } + | { ok: true; profiles: VcMeetingSharedConsumerProfile[] } | { ok: false; fieldErrors: VcMeetingConsumerProfileFieldError[] }; /** @@ -201,14 +246,16 @@ type DtoValidation = * (role 参与 profileHash,改写会造成不必要的 epoch 变更),新 id 用 id 作 * role。custom 档只复用同 id 既有 capabilities/ownedSinks,新 id 无可复用 * policy → fieldError。 + * + * 输出**不含** `agentAppId`:共享目录不绑定执行方。 */ export function vcMeetingConsumerProfilesFromDtos( dtos: readonly VcMeetingConsumerProfileDto[], - existing: readonly VcMeetingConsumerProfileConfig[], + existing: readonly VcMeetingSharedConsumerProfile[], ): DtoValidation { const fieldErrors: VcMeetingConsumerProfileFieldError[] = []; const existingById = new Map(existing.map(profile => [profile.id, profile] as const)); - const profiles: VcMeetingConsumerProfileConfig[] = []; + const profiles: VcMeetingSharedConsumerProfile[] = []; dtos.forEach((dto, index) => { const path = (field: string): string => `profiles[${index}].${field}`; if (!dto || typeof dto !== 'object' || Array.isArray(dto)) { @@ -219,10 +266,6 @@ export function vcMeetingConsumerProfilesFromDtos( fieldErrors.push({ path: path('id'), message: 'id 不能为空' }); return; } - if (typeof dto.agentAppId !== 'string' || !dto.agentAppId.trim()) { - fieldErrors.push({ path: path('agentAppId'), message: '必须选择一个 Agent' }); - return; - } if (dto.responseMode !== 'silent' && dto.responseMode !== 'listener_thread') { fieldErrors.push({ path: path('responseMode'), message: '输出方式必须是 silent 或 listener_thread' }); return; @@ -287,7 +330,6 @@ export function vcMeetingConsumerProfilesFromDtos( const instructions = dto.instructions?.trim(); profiles.push({ id: dto.id.trim(), - agentAppId: dto.agentAppId.trim(), ...(label ? { label } : {}), role: prior?.role ?? dto.id.trim(), ...(instructions ? { instructions } : {}), @@ -325,6 +367,16 @@ export function buildVcMeetingAgentOptions( } catch { workingDirReady = false; } + const vc = bot.vcMeetingAgent; + const textOutputPolicy = normalizeOutputPolicy(vc?.meetingConsumer?.textOutputPolicy); + const voiceOutputPolicy = normalizeOutputPolicy(vc?.meetingConsumer?.voiceOutputPolicy); + // 实时语音能力默认开启:未配 = 开,只有显式 false 才关(与 daemon 的 + // vcMeetingRealtimeVoiceEnabled 保持同一判定)。语音 WS 仍按需建连,能力开 + // 不等于入会即连。 + const realtimeVoiceEnabled = vc?.realtimeVoice?.enabled !== false; + // apiOnly(core-only)bot 没有飞书连接,收不到会议事件——与 bot-registry 的 + // vcMeetingAgentConfigActive fail-close 保持同一判定。 + const vcEligible = bot.apiOnly !== true; return { appId: bot.larkAppId, label: bot.displayName || deps.onlineBotName(bot.larkAppId) || bot.name || bot.larkAppId, @@ -334,17 +386,106 @@ export function buildVcMeetingAgentOptions( reliableTurnTerminal: deps.adapterReliableTurnTerminal(bot.cliId, bot.cliPathOverride), managedSideEffectEligible: deps.managedSideEffectEligible(bot), sandboxIsolated: deps.sandboxIsolated(bot), + // 缺省 = 开启:VC 对每个连着飞书的 bot 默认可用,enabled:false 才是退出。 + vcEnabled: vcEligible && vc?.enabled !== false, + vcEligible, + textOutputPolicy, + voiceOutputPolicy, + realtimeVoiceEnabled, + catalogDefaultConsumerId: normalizeNonEmptyStringOrNull(vc?.meetingConsumer?.catalogDefaultConsumerId), + // Mirrors daemon defaultVcMeetingTextOutputPolicy / defaultVcMeetingVoiceOutputPolicy. + effectiveTextOutputPolicy: textOutputPolicy ?? 'allow', + effectiveVoiceOutputPolicy: !realtimeVoiceEnabled ? 'deny' : (voiceOutputPolicy ?? 'allow'), }; }).sort((a, b) => (a.appId === b.appId ? 0 : a.appId < b.appId ? -1 : 1)); } +function normalizeOutputPolicy(value: unknown): VcMeetingOutputPolicyValue | null { + return value === 'allow' || value === 'approval' || value === 'deny' ? value : null; +} + +function normalizeNonEmptyStringOrNull(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function parseBotOutputPolicyPatches( + raw: unknown, + knownAppIds: ReadonlySet, + knownProfileIds: ReadonlySet, +): { ok: true; patches: VcMeetingBotOutputPolicyPatch[] } | { ok: false; fieldErrors: VcMeetingConsumerProfileFieldError[] } { + if (raw === undefined) return { ok: true, patches: [] }; + if (!Array.isArray(raw)) { + return { ok: false, fieldErrors: [{ path: 'botOutputPolicies', message: 'botOutputPolicies 必须是数组' }] }; + } + const fieldErrors: VcMeetingConsumerProfileFieldError[] = []; + const patches: VcMeetingBotOutputPolicyPatch[] = []; + const seen = new Set(); + raw.forEach((item, index) => { + const path = (field: string): string => `botOutputPolicies[${index}].${field}`; + if (!item || typeof item !== 'object' || Array.isArray(item)) { + fieldErrors.push({ path: `botOutputPolicies[${index}]`, message: '必须是对象' }); + return; + } + const record = item as Record; + const appId = typeof record.appId === 'string' ? record.appId.trim() : ''; + if (!appId || !knownAppIds.has(appId)) { + fieldErrors.push({ path: path('appId'), message: '未知的 bot appId' }); + return; + } + if (seen.has(appId)) { + fieldErrors.push({ path: path('appId'), message: '同一 bot 重复出现' }); + return; + } + seen.add(appId); + const parsePolicy = (field: 'textOutputPolicy' | 'voiceOutputPolicy'): VcMeetingOutputPolicyValue | null | undefined => { + const value = record[field]; + if (value === null) return null; + if (value === 'allow' || value === 'approval' || value === 'deny') return value; + fieldErrors.push({ path: path(field), message: '必须是 allow/approval/deny 或 null' }); + return undefined; + }; + const textOutputPolicy = parsePolicy('textOutputPolicy'); + const voiceOutputPolicy = parsePolicy('voiceOutputPolicy'); + if (typeof record.realtimeVoiceEnabled !== 'boolean') { + fieldErrors.push({ path: path('realtimeVoiceEnabled'), message: '必须是布尔值' }); + return; + } + // 老客户端不发 vcEnabled:缺省保持「接收」,与 vcMeetingAgent.enabled 缺省 + // 语义一致,绝不能把没提交这个字段解释成「关闭接收」。 + if (record.vcEnabled !== undefined && typeof record.vcEnabled !== 'boolean') { + fieldErrors.push({ path: path('vcEnabled'), message: '必须是布尔值' }); + return; + } + // per-bot 默认角色:null/缺省 = 跟随全局默认;给了字符串则必须命中本次提交的 + // 目录角色 id(不然存了个悬空默认,会静默回落全局,误导操作者)。 + let catalogDefaultConsumerId: string | null = null; + const rawDefault = record.catalogDefaultConsumerId; + if (rawDefault !== undefined && rawDefault !== null && rawDefault !== '') { + if (typeof rawDefault !== 'string' || !knownProfileIds.has(rawDefault)) { + fieldErrors.push({ path: path('catalogDefaultConsumerId'), message: '必须是本目录中存在的角色 id 或留空' }); + return; + } + catalogDefaultConsumerId = rawDefault; + } + if (textOutputPolicy === undefined || voiceOutputPolicy === undefined) return; + patches.push({ + appId, + vcEnabled: record.vcEnabled === undefined ? true : record.vcEnabled as boolean, + textOutputPolicy, + voiceOutputPolicy, + realtimeVoiceEnabled: record.realtimeVoiceEnabled, + catalogDefaultConsumerId, + }); + }); + return fieldErrors.length > 0 ? { ok: false, fieldErrors } : { ok: true, patches }; +} + function snapshotBody( - snapshot: VcMeetingConsumerProfilesSnapshot, + snapshot: VcMeetingSharedConsumerCatalogSnapshot, agentOptions: VcMeetingAgentOptionDto[], ): VcMeetingConsumerProfilesGetBody { return { ok: true, - listenerBotAppId: snapshot.listenerBotAppId, revision: snapshot.revision, catalogState: snapshot.catalogState, defaultMode: snapshot.defaultMode, @@ -352,24 +493,18 @@ function snapshotBody( profiles: snapshot.profiles.map(vcMeetingConsumerProfileToDto), agentOptions, templateCatalog: VC_MEETING_CONSUMER_PROFILE_TEMPLATE_CATALOG, - ...(snapshot.migrationOffer ? { migrationOffer: snapshot.migrationOffer } : {}), }; } export async function handleVcMeetingConsumerProfilesGet( - listenerBotAppId: string, deps: VcMeetingConsumerProfilesApiDeps, ): Promise { - if (!listenerBotAppId.trim()) { - return { status: 400, body: { ok: false, error: 'listenerBotAppId_required' } }; - } - let snapshot: VcMeetingConsumerProfilesSnapshot | undefined; + let snapshot: VcMeetingSharedConsumerCatalogSnapshot; try { - snapshot = await deps.readSnapshot(listenerBotAppId.trim()); + snapshot = await deps.readCatalog(); } catch { return { status: 503, body: { ok: false, error: 'config_unavailable' } }; } - if (!snapshot) return { status: 404, body: { ok: false, error: 'bot_not_in_config' } }; return { status: 200, body: snapshotBody(snapshot, buildVcMeetingAgentOptions(deps)) }; } @@ -381,10 +516,6 @@ export async function handleVcMeetingConsumerProfilesPut( return { status: 400, body: { ok: false, error: 'bad_json' } }; } const request = payload as Partial; - const listenerBotAppId = typeof request.listenerBotAppId === 'string' ? request.listenerBotAppId.trim() : ''; - if (!listenerBotAppId) { - return { status: 400, body: { ok: false, error: 'listenerBotAppId_required' } }; - } if (typeof request.expectedRevision !== 'string' || !request.expectedRevision) { return { status: 400, body: { ok: false, error: 'expectedRevision_required' } }; } @@ -420,13 +551,12 @@ export async function handleVcMeetingConsumerProfilesPut( }; } - let current: VcMeetingConsumerProfilesSnapshot | undefined; + let current: VcMeetingSharedConsumerCatalogSnapshot; try { - current = await deps.readSnapshot(listenerBotAppId); + current = await deps.readCatalog(); } catch { return { status: 503, body: { ok: false, error: 'config_unavailable' } }; } - if (!current) return { status: 404, body: { ok: false, error: 'bot_not_in_config' } }; const mapped = vcMeetingConsumerProfilesFromDtos( request.profiles as VcMeetingConsumerProfileDto[], @@ -439,9 +569,27 @@ export async function handleVcMeetingConsumerProfilesPut( }; } - // defaultConsumerIds 原样提交:未知/重复/agents-空组合由 store 严格拒绝, - // 本层不做静默过滤(与 store 的 fail-loud 语义保持一致)。 - const updated = await deps.updateSnapshot(listenerBotAppId, { + let knownAppIds: ReadonlySet; + try { + knownAppIds = new Set(deps.loadBotConfigs().map(bot => bot.larkAppId)); + } catch { + knownAppIds = new Set(); + } + const parsedPolicies = parseBotOutputPolicyPatches( + request.botOutputPolicies, + knownAppIds, + new Set(mapped.profiles.map(profile => profile.id)), + ); + if (!parsedPolicies.ok) { + return { + status: 422, + body: { ok: false, error: 'validation_failed', fieldErrors: parsedPolicies.fieldErrors }, + }; + } + + // defaultConsumerIds 原样提交:未知/重复/agents-空组合以及「同时选中两个角色」 + // 由 store 严格拒绝,本层不做静默过滤(与 store 的 fail-loud 语义保持一致)。 + const updated = await deps.updateCatalog({ expectedRevision: request.expectedRevision, defaultMode: request.defaultMode, defaultConsumerIds: [...request.defaultConsumerIds], @@ -461,16 +609,42 @@ export async function handleVcMeetingConsumerProfilesPut( }, }; } - if (updated.reason === 'bot_not_in_config') { - return { status: 404, body: { ok: false, error: 'bot_not_in_config' } }; - } return { status: 503, body: { ok: false, error: 'config_unavailable' } }; } + // Per-bot switches go to bots.json through the locked RMW path. The shared + // catalog is already committed at this point; a policy failure is surfaced + // loudly (503) so the UI re-GETs and shows what actually landed. + const policyFailures: VcMeetingConsumerProfileFieldError[] = []; + for (const patch of parsedPolicies.patches) { + try { + const applied = await deps.applyBotOutputPolicy(patch); + if (!applied.ok) { + policyFailures.push({ + path: `botOutputPolicies[${patch.appId}]`, + message: applied.reason ?? 'write_failed', + }); + } + } catch (err) { + policyFailures.push({ + path: `botOutputPolicies[${patch.appId}]`, + message: err instanceof Error ? err.message : String(err), + }); + } + } + try { - await deps.reloadDaemons([listenerBotAppId]); + // 共享目录不需要 reload:它在 daemon 侧走 mtime 缓存的 live 读,下一个会议 + // 事件自然生效。只有落进 bots.json 的 per-bot 开关需要通知对应 daemon。 + await deps.reloadDaemons(parsedPolicies.patches.map(patch => patch.appId)); } catch { // 配置已落盘;reload 失败只影响热加载时效,下次 daemon 重启/重载自然收敛。 } + if (policyFailures.length > 0) { + return { + status: 503, + body: { ok: false, error: 'bot_policy_write_failed', fieldErrors: policyFailures }, + }; + } return { status: 200, body: snapshotBody(updated.snapshot, buildVcMeetingAgentOptions(deps)) }; } diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index e30e3fd60..dde087e87 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1349,11 +1349,6 @@ const zh = { 'settings.sectionVcMeetingAgent': '会议监听', 'settings.vcMeetingAgent': '允许本机使用会议监听能力', 'settings.vcMeetingAgentHelp': '默认开启。控制本机 bot 是否接收新的会议监听事件。关闭后不再恢复监听;已进行中的会议通常自然结束。', - 'settings.vcMeetingListenerBot': '会议事件接收 Bot', - 'settings.vcMeetingListenerBotAuto': '按 bot 自身配置', - 'settings.vcMeetingListenerBotHelp': '指定接收会议事件的 Bot。保存时校验 lark-cli profile 与会议权限,校验通过后启用监听。', - 'settings.vcMeetingListenerBotDisabled': '保存后自动启用', - 'settings.vcMeetingListenerBotNoProfile': '需先配置 profile', 'settings.larkCliReady': 'lark-cli {version} · 版本符合要求', 'settings.larkCliOutdated': 'lark-cli {version} 版本过低,VC bot 入会需要 ≥ {minimum}', 'settings.larkCliMissing': '未检测到 lark-cli,VC bot 入会前需安装', @@ -1362,18 +1357,36 @@ const zh = { 'settings.feishuLoginClose': '关闭登录二维码', 'settings.vcProfiles.title': '会议角色预设', 'settings.vcProfiles.help': '为会议 agent 定义可复用的角色:职责说明、事件过滤、输出方式与权限模板。已激活成员沿用激活时冻结的版本;移除后重新加入才取新版。', - 'settings.vcProfiles.listenerOwner': '配置所属 Listener', - 'settings.vcProfiles.configuringBot': '正在配置:{bot}', - 'settings.vcProfiles.migrationOffer': '检测到旧版生成的会议纪要预设。可升级为全能力默认角色:监听群回复可直接发送;会中文字和语音必须经过受管输出闸门,文本默认审批,语音还需 Listener 语音设施已启用。', - 'settings.vcProfiles.migrationEnable': '升级并启用全能力默认纪要(保存后生效)', - 'settings.vcProfiles.noEligibleDefaultAgent': '暂时无法生成默认角色:请至少为一个支持可靠回执的角色执行 Bot 配置默认工作目录。', - 'settings.vcProfiles.legacyCatalog': '检测到旧版或未完成的会议 Agent 配置;保存角色预设后将切换到多角色模式。', + 'settings.vcProfiles.sharedNotice': '这份预设由所有 bot 共享:谁被拉进会议,就由谁执行选中的角色,不需要(也不能)在预设里指定执行 Bot。', + 'settings.vcProfiles.noEligibleDefaultAgent': '当前没有任何 bot 能真正执行会议角色:请至少为一个支持可靠回执的 bot 配置默认工作目录(详见下方按 bot 的提示)。', 'settings.vcProfiles.templates.title': '角色模板库', 'settings.vcProfiles.templates.builtinBadge': '内置', - 'settings.vcProfiles.templates.help': '从模板开始,再按你的场景修改 Agent、职责、过滤和输出方式。应用模板只会创建一份可编辑副本,不会与模板持续绑定。', + 'settings.vcProfiles.botPolicies.title': '按 Bot 的会议开关', + 'settings.vcProfiles.botPolicies.help': '「接收会议事件」关掉的 bot 被拉进会也不会开工。「默认角色」从共享目录里给这个 bot 挑一个进会默认跑的角色,留空则跟随全局默认。会中发言按 bot 控制:文字/语音各自可设为直接发送、需审核或禁止。实时语音默认开启(可按 bot 关闭);语音连接按需建立,只在这个 bot 真要发言时才连,不会空挂。', + 'settings.vcProfiles.botPolicies.vcEnabled': '接收会议事件', + 'settings.vcProfiles.botPolicies.searchPlaceholder': '搜索 bot 名称或 appId…', + 'settings.vcProfiles.botPolicies.noMatch': '没有匹配的 bot', + 'settings.vcProfiles.botPolicies.defaultProfile': '默认角色', + 'settings.vcProfiles.botPolicies.defaultProfileFollowGlobal': '跟随全局默认', + 'settings.vcProfiles.botPolicies.vcIneligible': '无飞书连接(apiOnly),收不到会议事件', + 'settings.vcProfiles.botPolicies.vcOff': '不接收会议事件', + 'settings.vcProfiles.botPolicies.preflight': '配置权限', + 'settings.vcProfiles.botPolicies.preflightHelp': '兜底用:VC 权限现在会在 daemon 启动时自动体检(用 bot 自己的凭证,无需人工),缺了会在日志报错并私信 owner。只有当某个 bot 确实缺权限、或需要在开放平台补订阅会议事件(需扫码登录一次)时,才点这个按钮补齐。lark-cli 入会身份已自动补齐,不再需要手动点。', + 'settings.vcProfiles.botPolicies.preflightRunning': '配置中…', + 'settings.vcProfiles.botPolicies.preflightOk': '✅ 权限与事件订阅已就绪', + 'settings.vcProfiles.botPolicies.text': '文字', + 'settings.vcProfiles.botPolicies.voice': '语音', + 'settings.vcProfiles.botPolicies.default': '默认', + 'settings.vcProfiles.botPolicies.allow': '直接发送', + 'settings.vcProfiles.botPolicies.approval': '需审核', + 'settings.vcProfiles.botPolicies.deny': '禁止', + 'settings.vcProfiles.botPolicies.realtimeVoice': '实时语音', + 'settings.vcProfiles.botPolicies.effective': '生效:', + 'settings.vcProfiles.templates.help': '从模板开始,再按你的场景修改职责、过滤和输出方式。应用模板只会创建一份可编辑副本,不会与模板持续绑定。', 'settings.vcProfiles.templates.use': '使用此模板', 'settings.vcProfiles.list.title': '我的预设', - 'settings.vcProfiles.list.help': '你维护的会议角色。点击卡片查看和编辑详情;勾选默认后,未操作飞书选择卡时会自动启用。', + 'settings.vcProfiles.list.help': '所有 bot 共用的会议角色。点击卡片查看和编辑详情;设为默认后,作为「默认行为」在选择卡超时无人操作时自动启用。', + 'settings.vcProfiles.defaultSingleHint': '默认角色只能有一个:一个 bot 进会后只跑一个角色。再点一次已选中的即可取消,回到仅监听。', 'settings.vcProfiles.list.empty': '还没有角色预设。可以新建空白预设,或从下方官方模板开始。', 'settings.vcProfiles.setDefault': '设为默认', 'settings.vcProfiles.viewDetails': '查看详情', @@ -1383,10 +1396,8 @@ const zh = { 'settings.vcProfiles.close': '关闭', 'settings.vcProfiles.done': '完成编辑', 'settings.vcProfiles.needAuth': '会议角色预设需要授权访问后编辑。', - 'settings.vcProfiles.discardConfirm': '有未保存的修改,切换后将丢弃。确认切换?', 'settings.vcProfiles.loading': '加载中…', 'settings.vcProfiles.loadFailed': '加载失败', - 'settings.vcProfiles.botNotInConfig': '该 bot 不在 bots.json 配置中', 'settings.vcProfiles.conflict': '配置已被其它端修改;请刷新后重新编辑,避免覆盖他人改动。', 'settings.vcProfiles.reload': '刷新', 'settings.vcProfiles.validationFailed': '校验未通过,请检查各字段', @@ -1396,8 +1407,6 @@ const zh = { 'settings.vcProfiles.idInvalid': '需以字母或数字开头,可含字母、数字、. _ -,最长 64 字符', 'settings.vcProfiles.fieldLabel': '名称', 'settings.vcProfiles.labelHelp': '仅用于展示的名称,可随时修改,例如「会议纪要员」。', - 'settings.vcProfiles.fieldAgent': '角色执行 Bot', - 'settings.vcProfiles.agentHelp': '指定由哪个 Bot 执行该角色。\n告警含义——离线:该 Bot 当前没有在线 daemon;未设默认目录:创建会议会话时可能没有合适的工作目录;不支持可靠回执:能运行,但异常恢复时会保守重放。\n同一场会议中,一个角色执行 Bot 最多承担一个角色。', 'settings.vcProfiles.agentOffline': '离线', 'settings.vcProfiles.agentNoWorkingDir': '未设默认目录', 'settings.vcProfiles.agentNoReliableTerminal': 'CLI 不支持可靠回执', @@ -1434,11 +1443,11 @@ const zh = { 'settings.vcProfiles.activity.magic_share_ended': '结束共享', 'settings.vcProfiles.remove': '删除此预设', 'settings.vcProfiles.add': '+ 新增预设', - 'settings.vcProfiles.defaultMode': '未操作选择卡时', - 'settings.vcProfiles.defaultModeHelp': '用户未操作选择卡时采用的组合。该组合需满足:ID 不重复、每个角色执行 Bot 最多承担一个角色、每种会中输出只有一个负责角色、最多一个角色自动回帖;冲突组合会在会议激活时被运行时裁决拒绝并回退为仅监听。', + 'settings.vcProfiles.defaultMode': '默认行为', + 'settings.vcProfiles.defaultModeHelp': '机器人被拉进会议、且飞书选择卡超时无人操作时采用的默认行为。选「启用所选预设」则自动按上面设为默认的那个角色开工;选「仅监听」则只记录不发言。未设默认角色时,即使选了「启用所选预设」也会退化为仅监听。', 'settings.vcProfiles.defaultModeListenOnly': '仅监听', 'settings.vcProfiles.defaultModeAgents': '启用所选预设', - 'settings.vcProfiles.defaultConsumers': '未操作时启用的预设', + 'settings.vcProfiles.defaultConsumers': '默认行为启用的预设', 'settings.vcProfiles.save': '保存预设', 'settings.vcProfiles.saving': '保存中…', 'settings.vcProfiles.saved': '已保存', @@ -3689,11 +3698,6 @@ const en: Record = { 'settings.sectionVcMeetingAgent': 'Meeting Listener', 'settings.vcMeetingAgent': 'Allow meeting listener features on this machine', 'settings.vcMeetingAgentHelp': 'On by default. Controls whether bots on this host accept new meeting listener events. Off also skips listener restore; running meetings usually finish naturally.', - 'settings.vcMeetingListenerBot': 'Meeting event receiver Bot', - 'settings.vcMeetingListenerBotAuto': 'Follow each bot config', - 'settings.vcMeetingListenerBotHelp': 'Chooses the Bot that receives meeting events. Saving validates its lark-cli profile and meeting scopes, then enables the listener.', - 'settings.vcMeetingListenerBotDisabled': 'auto-enable on save', - 'settings.vcMeetingListenerBotNoProfile': 'profile required first', 'settings.larkCliReady': 'lark-cli {version} · version requirement met', 'settings.larkCliOutdated': 'lark-cli {version} is too old; VC bot joining requires ≥ {minimum}', 'settings.larkCliMissing': 'lark-cli was not detected; install it before using VC bot joining', @@ -3702,18 +3706,36 @@ const en: Record = { 'settings.feishuLoginClose': 'Close login QR code', 'settings.vcProfiles.title': 'Meeting role presets', 'settings.vcProfiles.help': 'Reusable roles for meeting agents: responsibility prompt, event filter, output mode, and permission template. Activated members keep the version frozen at activation; remove and re-add to pick up a new version.', - 'settings.vcProfiles.listenerOwner': 'Listener to configure', - 'settings.vcProfiles.configuringBot': 'Configuring: {bot}', - 'settings.vcProfiles.migrationOffer': 'A meeting-minutes preset generated by an earlier version was found. Upgrade it to the full-capability default: listener-thread replies can be sent directly; in-meeting text and voice must pass through the managed output gate, text requires approval by default, and voice also requires enabled Listener voice facilities.', - 'settings.vcProfiles.migrationEnable': 'Upgrade and enable full-capability minutes (save to apply)', - 'settings.vcProfiles.noEligibleDefaultAgent': 'A default role cannot be generated yet. Configure a default working directory for at least one role execution Bot whose CLI supports reliable turn receipts.', - 'settings.vcProfiles.legacyCatalog': 'A legacy or partial meeting-Agent policy was found. Saving role presets switches it to multi-role mode.', + 'settings.vcProfiles.sharedNotice': 'These presets are shared by every bot: whichever bot is pulled into a meeting runs the selected role, so a preset neither needs nor can name an execution Bot.', + 'settings.vcProfiles.noEligibleDefaultAgent': 'No bot can execute a meeting role yet. Configure a default working directory for at least one bot whose CLI supports reliable turn receipts (see the per-bot notes below).', 'settings.vcProfiles.templates.title': 'Role template library', 'settings.vcProfiles.templates.builtinBadge': 'Built in', - 'settings.vcProfiles.templates.help': 'Start with a template, then customize its Agent, responsibilities, filters, and output. Applying a template creates an editable copy with no ongoing link to the source.', + 'settings.vcProfiles.botPolicies.title': 'Per-bot meeting switches', + 'settings.vcProfiles.botPolicies.help': 'A bot with "Receive meeting events" off stays idle even when pulled into a meeting. "Default role" picks a role from the shared catalog for this bot to run by default on join; blank follows the global default. In-meeting speech is per bot: text/voice can each send directly, require review, or be denied. Realtime voice is on by default (can be turned off per bot); the voice connection is opened on demand — only when this bot actually needs to speak, never left idle.', + 'settings.vcProfiles.botPolicies.vcEnabled': 'Receive meeting events', + 'settings.vcProfiles.botPolicies.searchPlaceholder': 'Search bot name or appId…', + 'settings.vcProfiles.botPolicies.noMatch': 'No matching bot', + 'settings.vcProfiles.botPolicies.defaultProfile': 'Default role', + 'settings.vcProfiles.botPolicies.defaultProfileFollowGlobal': 'Follow global default', + 'settings.vcProfiles.botPolicies.vcIneligible': 'no Feishu connection (apiOnly); cannot receive meeting events', + 'settings.vcProfiles.botPolicies.vcOff': 'not receiving meeting events', + 'settings.vcProfiles.botPolicies.preflight': 'Grant permissions', + 'settings.vcProfiles.botPolicies.preflightHelp': 'Fallback only: VC scopes are now auto-checked at daemon startup (using the bot\'s own credentials, no human needed) — missing ones are logged as errors and DM\'d to the owner. Click this only when a bot is actually missing scopes, or to subscribe meeting events on the Open Platform (needs a one-time QR login). The lark-cli join profile is now filled in automatically and no longer needs a manual click.', + 'settings.vcProfiles.botPolicies.preflightRunning': 'Configuring…', + 'settings.vcProfiles.botPolicies.preflightOk': '✅ Scopes and event subscriptions ready', + 'settings.vcProfiles.botPolicies.text': 'Text', + 'settings.vcProfiles.botPolicies.voice': 'Voice', + 'settings.vcProfiles.botPolicies.default': 'Default', + 'settings.vcProfiles.botPolicies.allow': 'Send directly', + 'settings.vcProfiles.botPolicies.approval': 'Needs review', + 'settings.vcProfiles.botPolicies.deny': 'Denied', + 'settings.vcProfiles.botPolicies.realtimeVoice': 'Realtime voice', + 'settings.vcProfiles.botPolicies.effective': 'Effective:', + 'settings.vcProfiles.templates.help': 'Start with a template, then customize its responsibilities, filters, and output. Applying a template creates an editable copy with no ongoing link to the source.', 'settings.vcProfiles.templates.use': 'Use template', 'settings.vcProfiles.list.title': 'My presets', - 'settings.vcProfiles.list.help': 'Meeting roles you maintain. Open a card to view or edit it; mark it as default to enable it when the Feishu selection card is not used.', + 'settings.vcProfiles.list.help': 'Meeting roles shared by every bot. Open a card to view or edit it; mark one as default so it becomes the "Default behavior" auto-enabled when the selection card times out with no action.', + 'settings.vcProfiles.defaultSingleHint': 'Only one default role: a bot runs a single role per meeting. Click the selected one again to clear it and fall back to listen-only.', 'settings.vcProfiles.list.empty': 'No role presets yet. Create a blank preset or start from an official template below.', 'settings.vcProfiles.setDefault': 'Use by default', 'settings.vcProfiles.viewDetails': 'View details', @@ -3723,10 +3745,8 @@ const en: Record = { 'settings.vcProfiles.close': 'Close', 'settings.vcProfiles.done': 'Done editing', 'settings.vcProfiles.needAuth': 'Meeting role presets require authorized access to edit.', - 'settings.vcProfiles.discardConfirm': 'You have unsaved changes; switching will discard them. Continue?', 'settings.vcProfiles.loading': 'Loading…', 'settings.vcProfiles.loadFailed': 'Load failed', - 'settings.vcProfiles.botNotInConfig': 'This bot is not present in bots.json', 'settings.vcProfiles.conflict': 'The config changed elsewhere; reload before editing so you do not overwrite it.', 'settings.vcProfiles.reload': 'Reload', 'settings.vcProfiles.validationFailed': 'Validation failed, check the fields', @@ -3736,8 +3756,6 @@ const en: Record = { 'settings.vcProfiles.idInvalid': 'Must start with a letter/digit; letters, digits, . _ - only; max 64 chars', 'settings.vcProfiles.fieldLabel': 'Name', 'settings.vcProfiles.labelHelp': 'Display-only name, editable anytime, e.g. “Minutes taker”.', - 'settings.vcProfiles.fieldAgent': 'Role execution Bot', - 'settings.vcProfiles.agentHelp': 'Choose the Bot that executes this role.\nWarnings — offline: no live daemon for this Bot; no default working dir: meeting sessions may lack a suitable directory; no reliable receipts: it runs, but failure recovery replays conservatively.\nEach role execution Bot can run at most one role in a meeting.', 'settings.vcProfiles.agentOffline': 'offline', 'settings.vcProfiles.agentNoWorkingDir': 'no default working dir', 'settings.vcProfiles.agentNoReliableTerminal': 'CLI lacks reliable turn receipts', @@ -3774,11 +3792,11 @@ const en: Record = { 'settings.vcProfiles.activity.magic_share_ended': 'Share ended', 'settings.vcProfiles.remove': 'Delete this preset', 'settings.vcProfiles.add': '+ Add preset', - 'settings.vcProfiles.defaultMode': 'When no selection is made', - 'settings.vcProfiles.defaultModeHelp': 'This combination is used when the user takes no action on the selection card. It must have unique IDs, at most one role per role execution Bot, a single owner per in-meeting output kind, and at most one auto-posting role; conflicts are rejected by the runtime resolver at activation and fail closed to listen-only.', + 'settings.vcProfiles.defaultMode': 'Default behavior', + 'settings.vcProfiles.defaultModeHelp': 'The default behavior used when a bot joins a meeting and the Feishu selection card times out with no action. "Enable selected presets" starts the role marked as default above; "Listen only" records without speaking. With no default role set, "Enable selected presets" also falls back to listen-only.', 'settings.vcProfiles.defaultModeListenOnly': 'Listen only', 'settings.vcProfiles.defaultModeAgents': 'Enable selected presets', - 'settings.vcProfiles.defaultConsumers': 'Presets enabled when no selection is made', + 'settings.vcProfiles.defaultConsumers': 'Presets for the default behavior', 'settings.vcProfiles.save': 'Save presets', 'settings.vcProfiles.saving': 'Saving…', 'settings.vcProfiles.saved': 'Saved', diff --git a/src/dashboard/web/settings-page.tsx b/src/dashboard/web/settings-page.tsx index 8468b0622..b50750ad8 100644 --- a/src/dashboard/web/settings-page.tsx +++ b/src/dashboard/web/settings-page.tsx @@ -64,14 +64,6 @@ interface DashboardSettings { noVisibleOutputHint: boolean; vcMeetingAgent: { enabled: boolean; - listenerBotAppId: string | null; - listenerBotOptions: Array<{ - larkAppId: string; - botName?: string | null; - cliId?: string; - vcMeetingAgentEnabled?: boolean; - hasLarkCliProfile?: boolean; - }>; larkCliVersion?: string | null; larkCliMeetsRequirement?: boolean; larkCliMinVersion?: string; @@ -200,8 +192,6 @@ function parseSettings(s: any): DashboardSettings { noVisibleOutputHint: s?.noVisibleOutputHint === true, vcMeetingAgent: { enabled: s?.vcMeetingAgent?.enabled !== false, - listenerBotAppId: typeof s?.vcMeetingAgent?.listenerBotAppId === 'string' ? s.vcMeetingAgent.listenerBotAppId : null, - listenerBotOptions: Array.isArray(s?.vcMeetingAgent?.listenerBotOptions) ? s.vcMeetingAgent.listenerBotOptions : [], larkCliVersion: s?.vcMeetingAgent?.larkCliVersion === undefined ? undefined : (s.vcMeetingAgent.larkCliVersion ?? null), larkCliMeetsRequirement: s?.vcMeetingAgent?.larkCliMeetsRequirement === true, larkCliMinVersion: typeof s?.vcMeetingAgent?.larkCliMinVersion === 'string' ? s.vcMeetingAgent.larkCliMinVersion : undefined, @@ -556,6 +546,7 @@ function SettingsPage() { updateBlock={updateBlock} feishuLoginQr={feishuLoginQr} onCloseFeishuLoginQr={() => setFeishuLoginQr(null)} + onFeishuLoginQr={setFeishuLoginQr} onSave={saveSettings} /> ) : loadError ? ( @@ -586,6 +577,8 @@ function SettingsBody(props: { updateBlock: ReactNode; feishuLoginQr: string | null; onCloseFeishuLoginQr(): void; + /** per-bot 前置配置失败且需要重新登录开放平台时,把二维码顶到本页已有的扫码面板。 */ + onFeishuLoginQr(qr: string | null): void; onSave(key: string, payload: unknown, optimistic: (settings: DashboardSettings) => DashboardSettings): Promise; }) { const tr = useT(); @@ -627,19 +620,6 @@ function SettingsBody(props: { { value: 'attach' as const, label: tr('settings.localCliOpenModeAttach') }, { value: 'resume' as const, label: tr('settings.localCliOpenModeResume') }, ], [tr]); - const vcListenerOptions = useMemo(() => [ - { value: '', label: tr('settings.vcMeetingListenerBotAuto') }, - ...settings.vcMeetingAgent.listenerBotOptions.map(bot => { - const label = bot.botName || bot.larkAppId; - const detail = bot.cliId ? ` · ${bot.cliId}` : ''; - const suffixParts = [ - bot.vcMeetingAgentEnabled === true ? undefined : tr('settings.vcMeetingListenerBotDisabled'), - bot.hasLarkCliProfile === true ? undefined : tr('settings.vcMeetingListenerBotNoProfile'), - ].filter(Boolean); - const suffix = suffixParts.length > 0 ? ` · ${suffixParts.join(' · ')}` : ''; - return { value: bot.larkAppId, label: `${label}${detail}${suffix}` }; - }), - ], [settings.vcMeetingAgent.listenerBotOptions, tr]); return (
{canWrite ? null : ( @@ -833,31 +813,11 @@ function SettingsBody(props: { ); }} /> -
- {tr('settings.vcMeetingListenerBot')} - { - const next = value || null; - void props.onSave( - 'vcMeetingAgent', - { vcMeetingAgent: { listenerBotAppId: next } }, - s => ({ ...s, vcMeetingAgent: { ...s.vcMeetingAgent, listenerBotAppId: next } }), - ); - }} - /> -
{props.feishuLoginQr ? (
diff --git a/src/dashboard/web/style.css b/src/dashboard/web/style.css index ebd66ef62..349fb0aeb 100644 --- a/src/dashboard/web/style.css +++ b/src/dashboard/web/style.css @@ -27642,3 +27642,25 @@ button.skills-issue-badge:hover { .feedback-more { margin-top: 12px; } @media (max-width: 1100px) { .feedback-kpis { grid-template-columns: repeat(3, 1fr); } .feedback-deliveries > div { grid-template-columns: 1fr 1fr; } } @media (max-width: 700px) { .feedback-kpis, .feedback-grid { grid-template-columns: 1fr; } } + +/* ── VC per-bot in-meeting output policy table ─────────────────────────── */ +.vc-bot-policy-table { display: flex; flex-direction: column; gap: 2px; } +.vc-bot-policy-row { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 6px 8px; border-radius: 6px; +} +.vc-bot-policy-row:nth-child(odd) { background: color-mix(in srgb, currentColor 4%, transparent); } +.vc-bot-policy-name { + flex: 1 1 160px; min-width: 140px; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; font-weight: 500; +} +.vc-bot-policy-cell { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; opacity: .92; } +.vc-bot-policy-select { font-size: 12px; padding: 2px 6px; border-radius: 4px; } +.vc-bot-policy-rtv input { accent-color: var(--accent, #4c8dff); } +.vc-bot-policy-effective { font-size: 11px; white-space: nowrap; } +.vc-bot-policy-warn { font-style: normal; margin-left: 6px; cursor: help; opacity: .85; } +.vc-bot-policy-search { + font-size: 12px; padding: 4px 8px; border-radius: 6px; + border: 1px solid var(--border, #d0d5dd); min-width: 180px; max-width: 260px; +} +.vc-bot-policy-empty { padding: 12px; text-align: center; } diff --git a/src/dashboard/web/vc-consumer-profiles-section.tsx b/src/dashboard/web/vc-consumer-profiles-section.tsx index 44dda507e..705a16162 100644 --- a/src/dashboard/web/vc-consumer-profiles-section.tsx +++ b/src/dashboard/web/vc-consumer-profiles-section.tsx @@ -2,6 +2,11 @@ * 「会议角色预设」编辑面(settings 页 · 会议 agent 区块内)。 * * 数据面:私有 API GET/PUT /api/vc-meeting/consumer-profiles。 + * + * 2026-08 起这份预设目录是**全 fleet 共享**的:不再按 bot 分开配置,也不再让用户 + * 手选「会议事件接收 Bot」。预设条目不带执行方——谁被拉进会议就由谁执行; + * 「哪些 bot 能接会议事件、能不能会中发言」改成下方按 bot 一行的开关。 + * * revision 乐观并发:PUT 带 expectedRevision,409 → 提示刷新(不覆盖他人修改); * 422 → fieldErrors 按 `profiles[i].field` / `defaultConsumerIds` 定位到输入项。 * permissionPreset 是 UI 概念:custom 只对「已保存的同 id 预设」可选(服务端 @@ -32,12 +37,6 @@ const ACTIVITY_TYPES = [ const INSTRUCTIONS_MAX = 8000; const PROFILE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; -/** - * Pre-provenance migration target. Keep this byte-for-byte aligned with the - * v2 generated minutes profile: the old seed has no marker, so the explicit - * Dashboard CTA is the only authority allowed to replace its instructions. - */ -const V2_DEFAULT_MINUTES_INSTRUCTIONS = '持续整理会议纪要,重点记录已确认的决策、待办事项(含负责人和截止时间)以及未解决风险;字幕修订时更新已有条目,不重复记录同一事项。仅在出现新的关键决策、明确待办或风险,或被用户点名时,才在监听群输出简洁增量;无实质增量时保持静默,不发送确认或心跳。需要向会议内发送文字或语音时,必须通过 botmux 受管 request-output/action gate 提交,不得绕过权限、所有权与审核策略。'; type FieldErrorMap = Record; @@ -48,17 +47,81 @@ interface DraftProfile extends VcMeetingConsumerProfileDto { isNew: boolean; } +type OutputPolicyValue = 'allow' | 'approval' | 'deny'; + +interface BotPolicyDraft { + appId: string; + label: string; + cliId?: string; + online: boolean; + /** 结构上能不能真的执行一个会议角色(工作目录 / 可靠回执 / 沙盒可交付)。 */ + workingDirReady: boolean; + reliableTurnTerminal: boolean; + managedSideEffectEligible: boolean; + sandboxIsolated: boolean; + /** apiOnly(无飞书连接)→ 结构上收不到会议事件,开关禁用。 */ + vcEligible: boolean; + vcEnabled: boolean; + textOutputPolicy: OutputPolicyValue | null; + voiceOutputPolicy: OutputPolicyValue | null; + realtimeVoiceEnabled: boolean; + /** per-bot 从共享目录挑的默认角色 id;null = 跟随全局默认。 */ + catalogDefaultConsumerId: string | null; + /** Serialized loaded state — save only submits rows whose current values differ. */ + baseline: string; +} + +function policyBaseline( + vcEnabled: boolean, + text: OutputPolicyValue | null, + voice: OutputPolicyValue | null, + rtv: boolean, + catalogDefaultConsumerId: string | null, +): string { + return `${vcEnabled ? '1' : '0'}|${text ?? ''}|${voice ?? ''}|${rtv ? '1' : '0'}|${catalogDefaultConsumerId ?? ''}`; +} + +function rowBaseline(row: BotPolicyDraft): string { + return policyBaseline(row.vcEnabled, row.textOutputPolicy, row.voiceOutputPolicy, row.realtimeVoiceEnabled, row.catalogDefaultConsumerId); +} + +function toBotPolicyDrafts(agentOptions: VcMeetingAgentOptionDto[]): BotPolicyDraft[] { + return agentOptions.map(agent => { + const text = agent.textOutputPolicy ?? null; + const voice = agent.voiceOutputPolicy ?? null; + const rtv = agent.realtimeVoiceEnabled === true; + const catalogDefaultConsumerId = agent.catalogDefaultConsumerId ?? null; + const vcEligible = agent.vcEligible !== false; + const vcEnabled = vcEligible && agent.vcEnabled !== false; + return { + appId: agent.appId, + label: agent.label || agent.appId, + ...(agent.cliId ? { cliId: agent.cliId } : {}), + online: agent.online, + workingDirReady: agent.workingDirReady, + reliableTurnTerminal: agent.reliableTurnTerminal, + managedSideEffectEligible: agent.managedSideEffectEligible, + sandboxIsolated: agent.sandboxIsolated, + vcEligible, + vcEnabled, + textOutputPolicy: text, + voiceOutputPolicy: voice, + realtimeVoiceEnabled: rtv, + catalogDefaultConsumerId, + baseline: policyBaseline(vcEnabled, text, voice, rtv, catalogDefaultConsumerId), + }; + }); +} + interface CatalogState { - /** 本 catalog 属于哪个 listener bot:save 用它而非当前下拉值,防跨 bot 写入。 */ - forBot: string; revision: string; - catalogState: 'uninitialized' | 'explicit_empty' | 'legacy_or_partial' | 'profiles'; + catalogState: 'uninitialized' | 'explicit_empty' | 'profiles'; defaultMode: 'listenOnly' | 'agents'; defaultConsumerIds: string[]; profiles: DraftProfile[]; agentOptions: VcMeetingAgentOptionDto[]; + botPolicies: BotPolicyDraft[]; templateCatalog: VcMeetingConsumerProfileTemplateCatalog; - migrationOffer?: 'enable_seeded_minutes_default'; } let uiKeySeq = 0; @@ -82,30 +145,21 @@ function toDto(draft: DraftProfile): VcMeetingConsumerProfileDto { } /** - * A meeting-consumer agent is SELECTABLE only when it can actually spawn and - * reply: it needs a working dir, reliable turn receipts, and (plan B) must not - * be a bot whose explicit sandbox request is undeliverable. Offline is transient - * and unsandboxed is an informed opt-out, so neither blocks selection. This is - * the single source of truth shared by the dropdown's `disabled` flag and the - * new-profile default seeder, so the two can never drift apart. Anything else — - * including seeding agentOptions[0] blindly — can silently persist an agent that - * joins meetings but never replies (the server PUT only checks the id is a - * non-empty string, not that it is eligible). + * 一个 bot 只有同时满足「有工作目录 + CLI 支持可靠回执 + 显式沙盒请求在本平台/ + * 后端可交付」才真的能执行一个会议角色。离线是暂态、未开沙盒是知情选择,都不算 + * 结构性阻塞。预设本身与 bot 解耦,所以这个判定只用来在按 bot 那张表上给出提示 + * ——不再拦着用户编辑预设。 */ -function isAgentSelectable(agent: VcMeetingAgentOptionDto): boolean { +function isAgentSelectable(agent: { + workingDirReady: boolean; + reliableTurnTerminal: boolean; + managedSideEffectEligible: boolean; +}): boolean { return agent.workingDirReady && agent.reliableTurnTerminal && agent.managedSideEffectEligible; } -/** appId of the first selectable agent (see {@link isAgentSelectable}), or '' - * when none qualifies — callers must not seed a disabled agent as the default. */ -function firstSelectableAgentAppId(agents: readonly VcMeetingAgentOptionDto[]): string { - return agents.find(isAgentSelectable)?.appId ?? ''; -} - -/** settings 页挂载门:预设 API 是私有端点,公共只读访客请求必 401—— - * canWrite=false 时完全不挂载编辑器(一次 GET 都不发),只显示提示。 */ /** 字段标题 + hover 帮助气泡:把配置语义讲清,避免用户对着裸表单猜。 */ function FieldHead(props: { title: string; help: string }): React.JSX.Element { return ( @@ -151,37 +205,26 @@ function VcProfileDialog(props: { ); } +/** settings 页挂载门:预设 API 是私有端点,公共只读访客请求必 401—— + * canWrite=false 时完全不挂载编辑器(一次 GET 都不发),只显示提示。 */ export function VcConsumerProfilesGate(props: { enabled: boolean; canWrite: boolean; - listenerBotAppId: string | null; - listenerBotOptions: Array<{ larkAppId: string; botName?: string | null }>; + onFeishuLoginQr?: (qr: string | null) => void; }) { const tr = useT(); if (!props.enabled) return null; if (!props.canWrite) return

{tr('settings.vcProfiles.needAuth')}

; - return ( - - ); + return ; } export function VcConsumerProfilesSection(props: { canWrite: boolean; - /** 全局设置里选的监听 bot;空 = 自动(编辑面回退到第一个候选)。 */ - listenerBotAppId: string | null; - listenerBotOptions: Array<{ larkAppId: string; botName?: string | null }>; + onFeishuLoginQr?: (qr: string | null) => void; }) { const tr = useT(); const locale = useDashboardLocale(); const mountedRef = useRef(false); - const options = props.listenerBotOptions; - const [targetBot, setTargetBot] = useState( - props.listenerBotAppId ?? options[0]?.larkAppId ?? '', - ); const [catalog, setCatalog] = useState(null); const [loadError, setLoadError] = useState(null); const [loading, setLoading] = useState(false); @@ -192,6 +235,11 @@ export function VcConsumerProfilesSection(props: { const [savedTick, setSavedTick] = useState(false); const [selectedProfileKey, setSelectedProfileKey] = useState(null); const [selectedTemplateId, setSelectedTemplateId] = useState(null); + /** 正在跑开放平台前置配置的 bot(一次只允许一个:automateOpenPlatformSetup 共用 + * 同一份开放平台会话,并发跑会互相抢 csrf/session)。 */ + const [preflightBusyAppId, setPreflightBusyAppId] = useState(null); + const [preflightResults, setPreflightResults] = useState>({}); + const [botPolicyQuery, setBotPolicyQuery] = useState(''); useEffect(() => { mountedRef.current = true; @@ -200,61 +248,27 @@ export function VcConsumerProfilesSection(props: { }; }, []); - /** 单调 load token:只有「最新一次 load」的响应才允许提交状态—— - * A→B 快速切换时,慢 A 响应到达后被丢弃,不会覆盖 B 的 catalog。 */ + /** 单调 load token:只有「最新一次 load」的响应才允许提交状态——重复点刷新时, + * 慢响应到达后被丢弃,不会覆盖新响应。 */ const loadSeqRef = useRef(0); - useEffect(() => { - // 全局监听 bot 变化时跟随;正在编辑时先保留旧 catalog,允许用户 - // 保存当前修改。保存/清 dirty 后本 effect 会再次运行并收敛到新的 - // listener,避免显式 Listener 模式(无切换下拉)永久卡在旧目标。 - if (props.listenerBotAppId && props.listenerBotAppId !== targetBot && !dirty) { - setTargetBot(props.listenerBotAppId); - } - }, [dirty, props.listenerBotAppId, targetBot]); - - const load = useCallback(async (bot: string) => { + const load = useCallback(async () => { const token = ++loadSeqRef.current; - // 切换目标立即清空编辑器:旧 bot 的 catalog 一刻也不能挂在新 target 下。 - setCatalog(null); setDirty(false); setConflict(false); setFieldErrors({}); setSelectedProfileKey(null); setSelectedTemplateId(null); - if (!bot) { - setLoadError(null); - return; - } setLoading(true); try { - const r = await fetch(`/api/vc-meeting/consumer-profiles?listenerBotAppId=${encodeURIComponent(bot)}`); + const r = await fetch('/api/vc-meeting/consumer-profiles'); const body = await r.json().catch(() => ({})); if (!mountedRef.current || loadSeqRef.current !== token) return; if (!r.ok || body?.ok !== true) { setCatalog(null); setLoadError(typeof body?.error === 'string' ? body.error : `HTTP ${r.status}`); } else { - setCatalog({ - forBot: bot, - revision: body.revision, - catalogState: body.catalogState === 'explicit_empty' - || body.catalogState === 'legacy_or_partial' - || body.catalogState === 'profiles' - ? body.catalogState - : 'uninitialized', - defaultMode: body.defaultMode === 'agents' ? 'agents' : 'listenOnly', - defaultConsumerIds: Array.isArray(body.defaultConsumerIds) ? body.defaultConsumerIds : [], - profiles: (Array.isArray(body.profiles) ? body.profiles : []).map(toDraft), - agentOptions: Array.isArray(body.agentOptions) ? body.agentOptions : [], - templateCatalog: body.templateCatalog?.schemaVersion === 1 - && Array.isArray(body.templateCatalog.templates) - ? body.templateCatalog - : { schemaVersion: 1, templates: [] }, - ...(body.migrationOffer === 'enable_seeded_minutes_default' - ? { migrationOffer: body.migrationOffer } - : {}), - }); + setCatalog(catalogFromBody(body)); setLoadError(null); setDirty(false); } @@ -268,8 +282,8 @@ export function VcConsumerProfilesSection(props: { }, []); useEffect(() => { - void load(targetBot); - }, [load, targetBot]); + void load(); + }, [load]); const mutate = useCallback((fn: (state: CatalogState) => CatalogState) => { setCatalog(current => (current ? fn(current) : current)); @@ -286,9 +300,7 @@ export function VcConsumerProfilesSection(props: { }, [mutate]); const save = useCallback(async () => { - // 提交目标取 catalog 自己记录的 bot:编辑器里的数据永远只能写回它来自的 - // bot;若与当前下拉不一致(切换中的窗口),直接拒绝。 - if (!catalog || saving || loading || !catalog.forBot || catalog.forBot !== targetBot) return; + if (!catalog || saving || loading) return; // 客户端预检仅拦截明显格式问题;权威校验在服务端(含 defaultConsumerIds 组合)。 const localErrors: FieldErrorMap = {}; catalog.profiles.forEach((profile, index) => { @@ -311,15 +323,24 @@ export function VcConsumerProfilesSection(props: { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - listenerBotAppId: catalog.forBot, expectedRevision: catalog.revision, defaultMode: catalog.defaultMode, defaultConsumerIds: catalog.defaultConsumerIds, profiles: catalog.profiles.map(toDto), + botOutputPolicies: catalog.botPolicies + .filter(row => rowBaseline(row) !== row.baseline) + .map(row => ({ + appId: row.appId, + vcEnabled: row.vcEnabled, + textOutputPolicy: row.textOutputPolicy, + voiceOutputPolicy: row.voiceOutputPolicy, + realtimeVoiceEnabled: row.realtimeVoiceEnabled, + catalogDefaultConsumerId: row.catalogDefaultConsumerId, + })), }), }); const body = await r.json().catch(() => ({})); - if (!mountedRef.current || catalog.forBot !== targetBot) return; + if (!mountedRef.current) return; if (r.status === 409) { setConflict(true); return; @@ -340,26 +361,7 @@ export function VcConsumerProfilesSection(props: { setFieldErrors({ profiles: typeof body?.error === 'string' ? body.error : `HTTP ${r.status}` }); return; } - setCatalog({ - forBot: catalog.forBot, - revision: body.revision, - catalogState: body.catalogState === 'explicit_empty' - || body.catalogState === 'legacy_or_partial' - || body.catalogState === 'profiles' - ? body.catalogState - : 'uninitialized', - defaultMode: body.defaultMode === 'agents' ? 'agents' : 'listenOnly', - defaultConsumerIds: Array.isArray(body.defaultConsumerIds) ? body.defaultConsumerIds : [], - profiles: (Array.isArray(body.profiles) ? body.profiles : []).map(toDraft), - agentOptions: Array.isArray(body.agentOptions) ? body.agentOptions : [], - templateCatalog: body.templateCatalog?.schemaVersion === 1 - && Array.isArray(body.templateCatalog.templates) - ? body.templateCatalog - : { schemaVersion: 1, templates: [] }, - ...(body.migrationOffer === 'enable_seeded_minutes_default' - ? { migrationOffer: body.migrationOffer } - : {}), - }); + setCatalog(catalogFromBody(body)); setDirty(false); setSavedTick(true); } catch (e) { @@ -368,64 +370,60 @@ export function VcConsumerProfilesSection(props: { } finally { if (mountedRef.current) setSaving(false); } - }, [catalog, saving, targetBot, tr]); - - const botOptions = useMemo(() => options.map(bot => ({ - value: bot.larkAppId, - label: bot.botName || bot.larkAppId, - })), [options]); - const targetBotLabel = botOptions.find(option => option.value === targetBot)?.label ?? targetBot; + }, [catalog, loading, saving, tr]); - const agentOptionItems = useMemo(() => { - if (!catalog) return []; - return catalog.agentOptions.map((agent) => { - const warnings = [ - agent.online ? undefined : tr('settings.vcProfiles.agentOffline'), - agent.workingDirReady ? undefined : tr('settings.vcProfiles.agentNoWorkingDir'), - agent.reliableTurnTerminal ? undefined : tr('settings.vcProfiles.agentNoReliableTerminal'), - agent.managedSideEffectEligible ? undefined : tr('settings.vcProfiles.agentNoManagedIsolation'), - // Plan B: unsandboxed is allowed but the bot credential is exposed to - // untrusted meeting input — surface it as an informational note, not a - // blocking warning (the agent stays selectable). - agent.managedSideEffectEligible && !agent.sandboxIsolated - ? tr('settings.vcProfiles.agentUnsandboxedRisk') - : undefined, - ].filter(Boolean); - // Hard blockers make the consumer un-spawnable, so disable selection - // outright rather than let the user pick a bot that will silently never - // reply. Offline is transient (a daemon may come back) and unsandboxed is - // an informed opt-out, so neither disables. Shares isAgentSelectable with - // the default seeder so a disabled bot can never sneak in as the default. - const blocked = !isAgentSelectable(agent); + const updateBotPolicy = useCallback(( + appId: string, + patch: Partial>, + ) => { + setCatalog(prev => { + if (!prev) return prev; return { - value: agent.appId, - disabled: blocked, - label: ( - - - {agent.label} - {agent.cliId ? {agent.cliId} : null} - - {warnings.length > 0 ? ( - ⚠ {warnings.join(' · ')} - ) : null} - - ), + ...prev, + botPolicies: prev.botPolicies.map(row => (row.appId === appId ? { ...row, ...patch } : row)), }; }); - }, [catalog, tr]); - - // 触发按钮只放得下一行:显示 agent 名字,有告警时加 ⚠ 前缀提示展开看详情。 - const agentTriggerLabel = (appId: string): ReactNode => { - const agent = catalog?.agentOptions.find(item => item.appId === appId); - if (!agent) return appId; - const warn = !agent.online - || !agent.workingDirReady - || !agent.reliableTurnTerminal - || !agent.managedSideEffectEligible - || !agent.sandboxIsolated; - return warn ? `⚠ ${agent.label}` : agent.label; - }; + setDirty(true); + setSavedTick(false); + }, []); + + /** 「配置权限」:给这个 bot 开 VC scope、订阅会议事件、补 larkCliProfile。 + * 刻意不在成功后 reload——preflight 写的是 bots.json,与预设草稿无关,reload 会 + * 把用户还没保存的策略编辑一起冲掉。 */ + const runPreflight = useCallback(async (appId: string) => { + if (preflightBusyAppId) return; + setPreflightBusyAppId(appId); + setPreflightResults(prev => ({ ...prev, [appId]: { ok: true, text: tr('settings.vcProfiles.botPolicies.preflightRunning') } })); + try { + const r = await fetch('/api/vc-meeting/bot-preflight', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ appId }), + }); + const body = await r.json().catch(() => ({})); + if (!mountedRef.current) return; + if (r.ok && body?.ok === true) { + props.onFeishuLoginQr?.(null); + setPreflightResults(prev => ({ ...prev, [appId]: { ok: true, text: tr('settings.vcProfiles.botPolicies.preflightOk') } })); + return; + } + if (typeof body?.feishuLoginQr === 'string' && body.feishuLoginQr) { + props.onFeishuLoginQr?.(body.feishuLoginQr); + } + setPreflightResults(prev => ({ + ...prev, + [appId]: { ok: false, text: typeof body?.error === 'string' ? body.error : `HTTP ${r.status}` }, + })); + } catch (e) { + if (!mountedRef.current) return; + setPreflightResults(prev => ({ + ...prev, + [appId]: { ok: false, text: e instanceof Error ? e.message : String(e) }, + })); + } finally { + if (mountedRef.current) setPreflightBusyAppId(null); + } + }, [preflightBusyAppId, props, tr]); const presetOptions = useCallback((profile: DraftProfile) => { const presets: VcMeetingPermissionPreset[] = ['observe_only', 'meeting_text', 'meeting_voice', 'meeting_text_voice']; @@ -447,12 +445,6 @@ export function VcConsumerProfilesSection(props: { uiKey, isNew: true, id: '', - // Seed the first SELECTABLE agent, not agentOptions[0]. The [0] entry is - // the appId-sorted first bot, which may be a disabled (un-spawnable) one - // — a hardcoded [0] default silently bypasses the dropdown disable and - // saves an agent that will never reply (server PUT only checks the id is - // a non-empty string). '' when none is eligible → validation catches it. - agentAppId: firstSelectableAgentAppId(state.agentOptions), responseMode: 'silent', listenerPlacement: 'auto', permissionPreset: 'observe_only', @@ -476,8 +468,6 @@ export function VcConsumerProfilesSection(props: { isNew: true, id, label: template.profileLabel[locale], - // Same as addProfile: first selectable agent, never the disabled [0]. - agentAppId: firstSelectableAgentAppId(state.agentOptions), instructions: template.instructions[locale], activityTypes: [...template.activityTypes], responseMode: template.responseMode, @@ -491,16 +481,34 @@ export function VcConsumerProfilesSection(props: { }, [locale, mutate]); const removeProfile = useCallback((uiKey: string) => { - mutate(state => ({ - ...state, - profiles: state.profiles.filter(profile => profile.uiKey !== uiKey), - defaultConsumerIds: state.defaultConsumerIds.filter(id => - state.profiles.some(profile => profile.uiKey !== uiKey && profile.id === id)), - })); + mutate((state) => { + const defaultConsumerIds = state.defaultConsumerIds.filter(id => + state.profiles.some(profile => profile.uiKey !== uiKey && profile.id === id)); + return { + ...state, + profiles: state.profiles.filter(profile => profile.uiKey !== uiKey), + defaultConsumerIds, + // 删掉的正是那条默认角色时,必须一并退回「仅监听」:agents + 空 ids 是 + // 服务端明确拒绝的组合,留着会让用户在保存时才吃到一个 422。 + defaultMode: defaultConsumerIds.length > 0 ? state.defaultMode : 'listenOnly', + }; + }); setSelectedProfileKey(current => current === uiKey ? null : current); }, [mutate]); - if (options.length === 0) return null; + const sortedBotPolicies = useMemo(() => { + if (!catalog) return []; + const q = botPolicyQuery.trim().toLowerCase(); + const rows = q + ? catalog.botPolicies.filter(row => + row.label.toLowerCase().includes(q) || row.appId.toLowerCase().includes(q)) + : catalog.botPolicies; + return [...rows].sort((a, b) => { + if (a.vcEnabled !== b.vcEnabled) return a.vcEnabled ? -1 : 1; + if (a.online !== b.online) return a.online ? -1 : 1; + return a.label.localeCompare(b.label); + }); + }, [catalog, botPolicyQuery]); const err = (path: string): string | undefined => fieldErrors[path]; // 保存/加载期间冻结全部编辑控件:PUT 用提交时的闭包,成功响应会整份 @@ -512,12 +520,13 @@ export function VcConsumerProfilesSection(props: { const selectedProfile = selectedProfileIndex >= 0 ? catalog?.profiles[selectedProfileIndex] ?? null : null; const selectedTemplate = catalog?.templateCatalog.templates.find(template => template.templateId === selectedTemplateId) ?? null; - const setProfileDefault = (profile: DraftProfile, enabled: boolean): void => { + /** 默认角色是单选:一个 bot 进会后只跑一个角色(服务端也这么校验), + * 再点一次已选中的那个即取消,回到「仅监听」。 */ + const setProfileDefault = (profile: DraftProfile): void => { if (!profile.id) return; mutate(state => { - const ids = enabled - ? [...new Set([...state.defaultConsumerIds, profile.id])] - : state.defaultConsumerIds.filter(id => id !== profile.id); + const already = state.defaultConsumerIds.length === 1 && state.defaultConsumerIds[0] === profile.id; + const ids = already ? [] : [profile.id]; return { ...state, defaultMode: ids.length > 0 ? 'agents' : 'listenOnly', @@ -530,42 +539,17 @@ export function VcConsumerProfilesSection(props: {
{tr('settings.vcProfiles.title')} - {props.listenerBotAppId === null && botOptions.length > 1 ? ( -
- {tr('settings.vcProfiles.listenerOwner')} - { - if (value === targetBot) return; - if (dirty && !window.confirm(tr('settings.vcProfiles.discardConfirm'))) return; - setTargetBot(value); - }} - /> -
- ) : ( - - {tr('settings.vcProfiles.configuringBot', { bot: targetBotLabel })} - - )}
+

{tr('settings.vcProfiles.sharedNotice')}

{tr('settings.vcProfiles.freezeNotice')}

{loading ?

{tr('settings.vcProfiles.loading')}

: null} {loadError ? ( -

- {loadError === 'bot_not_in_config' - ? tr('settings.vcProfiles.botNotInConfig') - : `${tr('settings.vcProfiles.loadFailed')}: ${loadError}`} -

+

{tr('settings.vcProfiles.loadFailed')}: {loadError}

) : null} {conflict ? (

{tr('settings.vcProfiles.conflict')}{' '} -

@@ -573,39 +557,8 @@ export function VcConsumerProfilesSection(props: { {err('profiles') ?

{err('profiles')}

: null} {catalog ? ( <> - {catalog.migrationOffer === 'enable_seeded_minutes_default' ? ( -

- {tr('settings.vcProfiles.migrationOffer')}{' '} - -

- ) : null} - {catalog.catalogState === 'uninitialized' - && catalog.profiles.length === 0 - && !hasStructurallyEligibleAgent ? ( -

{tr('settings.vcProfiles.noEligibleDefaultAgent')}

- ) : null} - {catalog.catalogState === 'legacy_or_partial' ? ( -

{tr('settings.vcProfiles.legacyCatalog')}

+ {!hasStructurallyEligibleAgent ? ( +

{tr('settings.vcProfiles.noEligibleDefaultAgent')}

) : null}
@@ -617,13 +570,7 @@ export function VcConsumerProfilesSection(props: {
{catalog.templateCatalog.templates.length > 0 ? (
@@ -703,6 +652,143 @@ export function VcConsumerProfilesSection(props: {
) : null} +
+
+
+ {tr('settings.vcProfiles.botPolicies.title')} +

{tr('settings.vcProfiles.botPolicies.help')}

+
+ setBotPolicyQuery(event.target.value)} + /> +
+
+ {sortedBotPolicies.length === 0 ? ( +

{tr('settings.vcProfiles.botPolicies.noMatch')}

+ ) : null} + {sortedBotPolicies.map(row => { + const effectiveText = row.textOutputPolicy ?? 'allow'; + const effectiveVoice = !row.realtimeVoiceEnabled ? 'deny' : (row.voiceOutputPolicy ?? 'allow'); + const policyLabel = (value: OutputPolicyValue): string => tr(`settings.vcProfiles.botPolicies.${value}`); + // 只在这里提示能力缺口:预设与 bot 解耦后,这张表是唯一能解释 + // 「为什么拉这个 bot 进会没反应」的地方。逐条文案很长(尤其沙盒那条), + // 一行 bot 后面平铺会喧宾夺主——收成一个 ⚠,详情放 hover title。 + const warnings = [ + row.vcEligible ? undefined : tr('settings.vcProfiles.botPolicies.vcIneligible'), + row.online ? undefined : tr('settings.vcProfiles.agentOffline'), + row.workingDirReady ? undefined : tr('settings.vcProfiles.agentNoWorkingDir'), + row.reliableTurnTerminal ? undefined : tr('settings.vcProfiles.agentNoReliableTerminal'), + row.managedSideEffectEligible ? undefined : tr('settings.vcProfiles.agentNoManagedIsolation'), + row.managedSideEffectEligible && !row.sandboxIsolated + ? tr('settings.vcProfiles.agentUnsandboxedRisk') + : undefined, + ].filter((w): w is string => !!w); + const rowFrozen = !props.canWrite || saving || loading; + const renderSelect = ( + field: 'textOutputPolicy' | 'voiceOutputPolicy', + value: OutputPolicyValue | null, + ): React.JSX.Element => ( + + ); + const preflight = preflightResults[row.appId]; + return ( +
+ + {row.online ? '' : '⚪ '}{row.label} + {warnings.length > 0 ? ( + + ) : null} + {preflight ? ( + + {preflight.text} + + ) : null} + + + + + + + + {tr('settings.vcProfiles.botPolicies.effective')} + {' '}{row.vcEnabled + ? `${policyLabel(effectiveText)} / ${policyLabel(effectiveVoice)}` + : tr('settings.vcProfiles.botPolicies.vcOff')} + + +
+ ); + })} +
+
{tr('settings.vcProfiles.defaultMode')} setSelectedProfileKey(null)} onUpdate={patch => updateProfile(selectedProfile.uiKey, patch)} onIdChange={(nextId) => { const oldId = selectedProfile.id; - mutate(state => ({ - ...state, - profiles: state.profiles.map(candidate => candidate.uiKey === selectedProfile.uiKey - ? { ...candidate, id: nextId } - : candidate), - defaultConsumerIds: state.defaultConsumerIds.map(id => - (id === oldId && oldId ? nextId : id)).filter(Boolean), - })); + mutate((state) => { + const defaultConsumerIds = state.defaultConsumerIds.map(id => + (id === oldId && oldId ? nextId : id)).filter(Boolean); + return { + ...state, + profiles: state.profiles.map(candidate => candidate.uiKey === selectedProfile.uiKey + ? { ...candidate, id: nextId } + : candidate), + defaultConsumerIds, + // 清空 id 会让默认角色随之消失;同上,不能留下 agents + 空 ids。 + defaultMode: defaultConsumerIds.length > 0 ? state.defaultMode : 'listenOnly', + }; + }); }} onRemove={() => removeProfile(selectedProfile.uiKey)} /> @@ -769,10 +858,7 @@ export function VcConsumerProfilesSection(props: { setSelectedTemplateId(null)} onUse={() => addProfileFromTemplate(selectedTemplate)} /> @@ -783,13 +869,30 @@ export function VcConsumerProfilesSection(props: { ); } +function catalogFromBody(body: Record & { [key: string]: any }): CatalogState { + const agentOptions: VcMeetingAgentOptionDto[] = Array.isArray(body.agentOptions) ? body.agentOptions : []; + return { + revision: body.revision, + catalogState: body.catalogState === 'explicit_empty' || body.catalogState === 'profiles' + ? body.catalogState + : 'uninitialized', + defaultMode: body.defaultMode === 'agents' ? 'agents' : 'listenOnly', + defaultConsumerIds: Array.isArray(body.defaultConsumerIds) ? body.defaultConsumerIds : [], + profiles: (Array.isArray(body.profiles) ? body.profiles : []).map(toDraft), + agentOptions, + botPolicies: toBotPolicyDrafts(agentOptions), + templateCatalog: body.templateCatalog?.schemaVersion === 1 + && Array.isArray(body.templateCatalog.templates) + ? body.templateCatalog + : { schemaVersion: 1, templates: [] }, + }; +} + function ProfileEditorDialog(props: { profile: DraftProfile; index: number; frozen: boolean; canWrite: boolean; - agentOptions: Array<{ value: string; label: ReactNode }>; - agentLabel: ReactNode; presetOptions: Array<{ value: VcMeetingPermissionPreset; label: string }>; error(path: string): string | undefined; onClose(): void; @@ -828,19 +931,6 @@ function ProfileEditorDialog(props: { /> {props.error(`profiles[${index}].label`) ? {props.error(`profiles[${index}].label`)} : null} -
- - props.onUpdate({ agentAppId: value })} - /> - {props.error(`profiles[${index}].agentAppId`) ? {props.error(`profiles[${index}].agentAppId`)} : null} -
; + +/** 全 fleet 共享的会议角色预设目录。任何没有自己 `consumerProfiles` 的 bot + * 都继承这份目录。 */ +export interface VcMeetingSharedConsumerCatalog { + profiles: VcMeetingSharedConsumerProfile[]; + defaultMode: 'listenOnly' | 'agents'; + defaultConsumerIds: string[]; +} + export interface VcMeetingAgentGlobalConfig { /** Machine-wide VC meeting listener kill-switch. Missing means enabled for * backwards compatibility; per-bot vcMeetingAgent.enabled still controls * whether a given bot responds to meetings. */ enabled?: boolean; - /** Optional bot app id that is allowed to own new VC meeting listeners. When - * unset, legacy per-bot vcMeetingAgent.enabled routing is preserved. */ + /** DEPRECATED (2026-08): the single-listener pin is retired — every bot with + * VC active handles the meeting events it receives. Kept only so an existing + * config round-trips without data loss; readers must ignore it. */ listenerBotAppId?: string; + /** 共享角色预设目录,见 {@link VcMeetingSharedConsumerCatalog}。 */ + consumerCatalog?: VcMeetingSharedConsumerCatalog; } export interface GlobalConfig { @@ -465,6 +485,31 @@ function readHostOverloadAlert(raw: unknown): HostOverloadAlertGlobalConfig | un return Object.keys(out).length > 0 ? out : undefined; } +/** + * 只做结构层解析(是不是数组 / defaultMode 枚举 / id 是不是非空串),字段级 + * 权威校验留给 bot-registry 的严格 normalizer——它在目录被合进某个 bot 时运行, + * 且失败只降级这一个 bot,不会让一份坏的全局目录把整个 fleet 的配置解析炸掉。 + */ +function readVcMeetingConsumerCatalog(raw: unknown): VcMeetingSharedConsumerCatalog | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const v = raw as Record; + if (!Array.isArray(v.profiles)) return undefined; + const profiles = v.profiles.filter( + (entry): entry is VcMeetingSharedConsumerProfile => + !!entry && typeof entry === 'object' && !Array.isArray(entry) + && typeof (entry as Record).id === 'string' + && (entry as Record).id !== '', + ); + const defaultConsumerIds = Array.isArray(v.defaultConsumerIds) + ? v.defaultConsumerIds.filter((id): id is string => typeof id === 'string' && id.trim() !== '') + : []; + return { + profiles, + defaultMode: v.defaultMode === 'agents' && defaultConsumerIds.length > 0 ? 'agents' : 'listenOnly', + defaultConsumerIds, + }; +} + /** Parse the `browserRestartTargets` array from config. Keep only entries with a * non-blank string bundleId; coerce the optional fields defensively so a * hand-edited config can't inject non-strings. Returns undefined when there's @@ -497,6 +542,8 @@ function readVcMeetingAgent(raw: unknown): VcMeetingAgentGlobalConfig | undefine if (typeof v.listenerBotAppId === 'string' && v.listenerBotAppId.trim()) { out.listenerBotAppId = v.listenerBotAppId.trim(); } + const catalog = readVcMeetingConsumerCatalog(v.consumerCatalog); + if (catalog) out.consumerCatalog = catalog; return Object.keys(out).length > 0 ? out : undefined; } @@ -624,11 +671,49 @@ export function globalVcMeetingAgentConfigLive(): VcMeetingAgentGlobalConfig { const config: VcMeetingAgentGlobalConfig = { enabled: parsed?.enabled !== false, ...(parsed?.listenerBotAppId ? { listenerBotAppId: parsed.listenerBotAppId } : {}), + ...(parsed?.consumerCatalog ? { consumerCatalog: parsed.consumerCatalog } : {}), }; vcMeetingAgentLiveCache = { path, mtimeMs, config }; return config; } +/** 共享角色预设目录(live 读,随 mtime 失效)。undefined = 还没配置过。 */ +export function globalVcMeetingSharedConsumerCatalog(): VcMeetingSharedConsumerCatalog | undefined { + return globalVcMeetingAgentConfigLive().consumerCatalog; +} + +/** + * 未经归一化的共享目录原始值(可能是 undefined / 任意形状)。写路径用它算乐观 + * 并发 revision——手改配置即使被 forgiving 读路径归一化掉,也必须让 revision 变。 + */ +export function rawGlobalVcMeetingSharedConsumerCatalog(): unknown { + const vcAgent = readRawConfig().vcMeetingAgent; + if (!vcAgent || typeof vcAgent !== 'object' || Array.isArray(vcAgent)) return undefined; + return (vcAgent as Record).consumerCatalog; +} + +/** + * 写共享角色预设目录。`null` 清空目录(所有 bot 回到「无预设」)。 + * + * `mergeGlobalConfig` 只做顶层 key 合并,所以这里先读出现有 `vcMeetingAgent` + * 对象再整体写回——否则会把同一层的 `enabled` 抹掉。未知字段原样保留。 + */ +export function writeGlobalVcMeetingSharedConsumerCatalog( + catalog: VcMeetingSharedConsumerCatalog | null, +): void { + const raw = readRawConfig(); + const current = raw.vcMeetingAgent && typeof raw.vcMeetingAgent === 'object' && !Array.isArray(raw.vcMeetingAgent) + ? { ...(raw.vcMeetingAgent as Record) } + : {}; + if (catalog === null) delete current.consumerCatalog; + else current.consumerCatalog = catalog; + mergeGlobalConfig({ + vcMeetingAgent: (Object.keys(current).length > 0 + ? current + : undefined) as GlobalConfig['vcMeetingAgent'], + }); +} + export function isGlobalVcMeetingAgentEnabled(): boolean { return globalVcMeetingAgentConfigLive().enabled !== false; } diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts index ce9467860..1a3fa9d15 100644 --- a/src/im/lark/event-dispatcher.ts +++ b/src/im/lark/event-dispatcher.ts @@ -8,7 +8,7 @@ import { ProxyAgent } from 'proxy-agent'; import { readFileSync, mkdirSync, existsSync } from 'node:fs'; import { atomicWriteFileSync } from '../../utils/atomic-write.js'; import { join } from 'node:path'; -import { getBot, getAllBots, findOncallChat, getOwnerOpenId, loadBotConfigs, type BotState } from '../../bot-registry.js'; +import { getBot, getAllBots, findOncallChat, getOwnerOpenId, loadBotConfigs, vcMeetingAgentConfigActive, type BotState } from '../../bot-registry.js'; import { config, isVcMeetingAgentGloballyEnabled, vcMeetingAgentGlobalListenerBotAppId } from '../../config.js'; import { getChatInfo, getChatMode, getCachedChatMode, getUserProfile, listChatMessagesUntil, resolveSiblingBotBySenderOpenId, replyMessage, sendMessage, sendUserMessage, isHumanOpenId, updateMessage } from './client.js'; import { logger } from '../../utils/logger.js'; @@ -35,7 +35,7 @@ import { buildEventSubDeepLink, buildScopeDeepLink, } from '../../setup/verify-permissions.js'; -import { automateOpenPlatformSetup } from '../../setup/open-platform-automation.js'; +import { automateOpenPlatformSetup, probeVcMeetingEventSubscription } from '../../setup/open-platform-automation.js'; import { type Brand, larkHosts, normalizeBrand, sdkDomain } from './lark-hosts.js'; import { tryHandleGrantCommand } from './grant-command.js'; import { tryHandleInviteCommand } from './invite-command.js'; @@ -454,10 +454,15 @@ export async function checkRequiredScopes(larkAppId: string): Promise { const globalVcListenerAppId = vcMeetingAgentGlobalListenerBotAppId(); if ( isVcMeetingAgentGloballyEnabled() - && bot.config.vcMeetingAgent?.enabled === true + // VC 现在默认对每个连着飞书的 bot 生效(enabled:false 才是显式退出),所以 + // 就绪自检也要跟着走 vcMeetingAgentConfigActive,而不是只认显式 enabled:true—— + // 否则「默认开」的绝大多数 bot 在启动时永远不做权限体检。 + && !!vcMeetingAgentConfigActive(bot.config) && (!globalVcListenerAppId || globalVcListenerAppId === larkAppId) ) { - const requiredVcScopes = bot.config.vcMeetingAgent.realtimeVoice?.enabled === true + // 实时语音也默认开启(未配=开),所以 VC 权限体检默认把实时语音 scope 纳入 + // 必需项;只有显式 realtimeVoice.enabled=false 才不查它。 + const requiredVcScopes = bot.config.vcMeetingAgent?.realtimeVoice?.enabled !== false ? [...VC_MEETING_FEATURE_SCOPES, ...VC_MEETING_REALTIME_VOICE_SCOPES] : VC_MEETING_FEATURE_SCOPES; const missingVc = requiredVcScopes.filter(s => !grantedScopes.has(s.name)); @@ -554,6 +559,75 @@ export async function checkRequiredScopes(larkAppId: string): Promise { } } +/** + * Startup: ensure this bot is subscribed to the VC meeting events + * (`vc.bot.meeting_*` + participant_meeting_joined) so ANY invited bot can + * receive meeting invites — the physical prerequisite for bot-agnostic + * auto-join. Check-first: a read-only probe over the cached Feishu web session + * (no QR at boot); only when events are missing / event mode is wrong do we run + * the full open-platform automation (which subscribes them AND publishes a + * version). This mirrors checkRequiredScopes ("fix only when something's + * missing") so a bot with events already subscribed makes zero mutating calls + * and never republishes. Best-effort: never throws into boot; apiOnly and + * VC-disabled bots are skipped by the caller / the active-config gate. + */ +export async function ensureVcMeetingEventsSubscribed(larkAppId: string): Promise { + const bot = getBot(larkAppId); + const brand = normalizeBrand(bot.config.brand); + // Open-platform automation only supports feishu.cn tenants. Skip apiOnly and + // VC-inactive bots — vcMeetingAgentConfigActive fail-closes both. + if (brand !== 'feishu') return; + if (!vcMeetingAgentConfigActive(bot.config)) return; + try { + const probe = await probeVcMeetingEventSubscription(larkAppId); + if (!probe.ok) { + // No cached web session / expired / network — degrade gracefully. Do NOT + // pop a QR at boot. Surface once to the admin so they can `botmux setup`. + logger.info( + `[${larkAppId}] VC event subscription check skipped (${probe.reason}): ${probe.message}. ` + + `被邀请进会需先订阅 vc.bot.meeting_* 事件;如该 bot 从未订阅,请运行 \`botmux setup\` 刷新开放平台登录态后重启。`, + ); + return; + } + if (probe.missingVcEvents.length === 0 && probe.eventModeReady) { + logger.info(`[${larkAppId}] VC meeting events already subscribed (long-connection mode)`); + return; + } + logger.info( + `[${larkAppId}] VC events missing (${probe.missingVcEvents.join('、') || '事件模式非长连接'}); auto-subscribing via Open Platform...`, + ); + const result = await automateOpenPlatformSetup({ + appId: bot.config.larkAppId, + brand, + maxWaitMs: 60_000, + disableQrLogin: true, + onStatus: (msg) => logger.info(`[${larkAppId}] vc-event-autoconfig: ${msg}`), + onQrCode: () => { + logger.warn(`[${larkAppId}] vc-event-autoconfig: cached Feishu web session expired; run \`botmux setup\` to refresh, then restart.`); + }, + }); + if (result.ok) { + logger.info(`[${larkAppId}] VC events auto-subscribed: ${result.subscribedEventCount} events, version ${result.versionId ?? 'n/a'} published`); + return; + } + // Automation failed (session expired mid-run / api error). Log; DM the admin + // once so a bot that genuinely lacks the subscription gets a human nudge. + logger.warn(`[${larkAppId}] VC event auto-subscribe failed (${result.reason}): ${result.message}`); + const adminOpenId = getAdminOpenId(bot); + if (adminOpenId) { + await dmAdmin( + larkAppId, + adminOpenId, + `⚠️ botmux 想在启动时自动为机器人 "${bot.botName ?? larkAppId}" 订阅视频会议事件(vc.bot.meeting_*),以便它被拉进会时能自动进会,但自动配置失败:${result.message}\n\n` + + `请运行 \`botmux setup\` 刷新飞书开放平台登录态后重启 daemon,botmux 会自动重试。`, + 'vc event auto-subscribe failed', + ); + } + } catch (err: any) { + logger.debug(`[${larkAppId}] VC event subscription check errored: ${err?.message ?? err}`); + } +} + // ─── Group chat stats cache ─────────────────────────────────────────────── // // chat.get returns both user_count (real users only) and bot_count (bots). diff --git a/src/services/vc-meeting-action-gate.ts b/src/services/vc-meeting-action-gate.ts index 20e3d4240..d3a6dd57d 100644 --- a/src/services/vc-meeting-action-gate.ts +++ b/src/services/vc-meeting-action-gate.ts @@ -558,13 +558,24 @@ export async function requestVcMeetingManagedAction( } // An origin that has not yet established an action must be the exact live - // dispatched attempt. In particular, attempt N may not create a terminal - // rejected action after attempt N+1 has taken over the same stable turn. + // dispatched attempt, OR the just-completed terminal attempt. In particular, + // attempt N may not create an action after attempt N+1 has taken over the same + // stable turn — the dispatchAttempt equality check below enforces that. + // + // Plan B: a meeting agent is an ordinary chat-scope session that typically + // decides to speak in-meeting AFTER it has finished processing the transcript + // turn (the delivery receipt is then `completed`, not `dispatched`). Accepting + // `completed` is safe: completion is TERMINAL, so no later attempt can take + // over it, and the dispatchAttempt equality check still pins the action to the + // exact attempt that ran. Only these two states are accepted; failed / + // ambiguous / abandoned still fail closed. (This gate is reached only by the + // in-meeting managed-action path — submitVcMeetingManagedAction — not by + // listener-group auto-post.) if (lookup.receipt.stableTurnId !== request.stableTurnId) { return errorResult(409, 'source_turn_mismatch', 'stable turn does not match the delivery receipt'); } - if (lookup.receipt.status !== 'dispatched') { - return errorResult(409, 'delivery_not_dispatched', `delivery is ${lookup.receipt.status}, not dispatched`); + if (lookup.receipt.status !== 'dispatched' && lookup.receipt.status !== 'completed') { + return errorResult(409, 'delivery_not_dispatched', `delivery is ${lookup.receipt.status}, not dispatched or completed`); } if (lookup.receipt.dispatchAttempt !== request.dispatchAttempt) { return errorResult(409, 'stale_dispatch_attempt', 'action origin does not match the live delivery attempt'); diff --git a/src/services/vc-meeting-consumer-profile-bootstrap.ts b/src/services/vc-meeting-consumer-profile-bootstrap.ts index 73778bb7a..a86d18d44 100644 --- a/src/services/vc-meeting-consumer-profile-bootstrap.ts +++ b/src/services/vc-meeting-consumer-profile-bootstrap.ts @@ -1,112 +1,37 @@ -import { createHash } from 'node:crypto'; -import type { BotConfig } from '../bot-registry.js'; -import { - effectiveDefaultWorkingDir, - parseBotConfigsFromText, -} from '../bot-registry.js'; -import { createCliAdapterSync } from '../adapters/cli/registry.js'; -import { config } from '../config.js'; -import { resolvePairedSpawnBackendType } from '../core/persistent-backend.js'; -import { canonicalJson } from '../utils/canonical-input-hash.js'; -import { rmwBotEntry } from './config-store.js'; -import { evaluateVcMeetingConsumerIsolation } from './vc-meeting-consumer-isolation.js'; - -export interface VcMeetingConsumerBootstrapAgent { - appId: string; - workingDirReady: boolean; - reliableTurnTerminal: boolean; - /** May this bot act as a meeting consumer at all? Plan B: true unless an - * explicit sandbox request is undeliverable (macOS/riff/herdr/zellij). */ - managedSideEffectEligible: boolean; - /** Is the managed sandbox boundary actually in force (credential masked + - * outbox relay)? false = unsandboxed, credential exposed to meeting input. */ - sandboxIsolated: boolean; -} - -export interface VcMeetingConsumerBootstrapAgentDeps { - workingDirReady(bot: BotConfig): boolean; - reliableTurnTerminal(bot: BotConfig): boolean; - managedSideEffectEligible(bot: BotConfig): boolean; - sandboxIsolated(bot: BotConfig): boolean; -} - -function evaluateBotConsumerIsolation(bot: BotConfig) { - const cliId = bot.cliId ?? config.daemon.cliId; - const backendType = resolvePairedSpawnBackendType( - cliId, - undefined, - bot.backendType, - config.daemon.backendType, - ); - return evaluateVcMeetingConsumerIsolation({ - sandbox: bot.sandbox, - platform: process.platform, - backendType, - }); -} - -const defaultAgentDeps: VcMeetingConsumerBootstrapAgentDeps = { - workingDirReady(bot) { - try { - return !!(effectiveDefaultWorkingDir(bot) ?? bot.workingDir); - } catch { - return false; - } - }, - reliableTurnTerminal(bot) { - if (!bot.cliId) return false; - try { - return createCliAdapterSync(bot.cliId, bot.cliPathOverride).reliableTurnTerminal === true; - } catch { - return false; - } - }, - managedSideEffectEligible(bot) { - return evaluateBotConsumerIsolation(bot).ok; - }, - sandboxIsolated(bot) { - const decision = evaluateBotConsumerIsolation(bot); - return decision.ok && decision.isolated; - }, -}; - -export function buildVcMeetingConsumerBootstrapAgents( - configs: readonly BotConfig[], - deps: VcMeetingConsumerBootstrapAgentDeps = defaultAgentDeps, -): VcMeetingConsumerBootstrapAgent[] { - return configs.map(bot => ({ - appId: bot.larkAppId, - workingDirReady: deps.workingDirReady(bot), - reliableTurnTerminal: deps.reliableTurnTerminal(bot), - managedSideEffectEligible: deps.managedSideEffectEligible(bot), - sandboxIsolated: deps.sandboxIsolated(bot), - })).sort((a, b) => (a.appId === b.appId ? 0 : a.appId < b.appId ? -1 : 1)); -} - /** - * Choose a durable receiver identity. Persisted legacy preferences, when a - * caller explicitly supplies them, win. Otherwise prefer the listener itself - * when structurally eligible, then fall back deterministically to another - * eligible agent. Online state is deliberately absent because it is transient. + * 历史遗留的「默认会议角色预设」识别。 + * + * 这个模块过去负责**生成**默认预设:daemon 启动时给本 bot 播种一条 `minutes` + * 预设,并把执行方 `agentAppId` 焊进去。整套播种已于 2026-08 退役——角色预设 + * 改成全 fleet 共享目录 + 读路径内置默认(`vc-meeting-shared-consumer-catalog.ts`), + * 执行方永远是收到这场会议事件的 bot 自己。退役的两个理由: + * + * 1. 播种出来的 per-bot `consumerProfiles` 会永久遮蔽共享目录,操作者改共享 + * 预设时被播种过的 bot 完全不跟随; + * 2. 播种时那条「本 bot 结构上不合格就换一个合格 bot」的兜底,会把**另一个** + * bot 的 appId 写进预设,会中 `addBotToChat` 再照着它把无关 bot 拉进监听群 + * ——「拉 A 进会却把 B 拉进群」就是这么来的。 + * + * 留下来的只有**识别**能力,三种: + * + * - {@link hasLegacyVcMeetingConsumerAgentPolicy}:操作者按老模型手写过执行方 + * 策略(`agentCandidates` 等)。绑定层据此不拿共享目录去覆盖它; + * - {@link isVcMeetingSeededConsumerProfileBlock}:带 `defaultProfileBootstrap` + * 出处标记的 v2 播种残留。绑定层用它把这类配置当作「没有 per-bot 预设」, + * 于是这些 bot 回到共享目录(否则机器播种物会永久遮蔽操作者的共享预设); + * - {@link isLegacyVcMeetingDefaultConsumerSeedCandidate}:更早的 v1 播种物没有 + * 出处标记,只能按逐字段形状识别。绑定层同样让它回到共享目录——v1 播种物一样 + * 把执行方焊进了预设,一样可能焊的是另一个 bot。 + * + * 识别刻意严格:近似形状可能是操作者自己写的,不能被当成机器生成物丢弃或迁移。 */ -export function selectVcMeetingDefaultConsumerAgent( - listenerBotAppId: string, - agents: readonly VcMeetingConsumerBootstrapAgent[], - preferredAgentAppIds: readonly string[] = [], -): VcMeetingConsumerBootstrapAgent | undefined { - const eligible = agents - .filter(agent => agent.workingDirReady - && agent.reliableTurnTerminal - && agent.managedSideEffectEligible) - .sort((a, b) => (a.appId === b.appId ? 0 : a.appId < b.appId ? -1 : 1)); - for (const appId of preferredAgentAppIds) { - const preferred = eligible.find(agent => agent.appId === appId); - if (preferred) return preferred; - } - return eligible.find(agent => agent.appId === listenerBotAppId) - ?? eligible.find(agent => agent.appId !== listenerBotAppId); -} +import type { VcMeetingConsumerConfig } from '../types.js'; +const DEFAULT_CONSUMER_PROFILE_ID = 'minutes'; +const DEFAULT_CONSUMER_PROFILE_LABEL = '会议纪要'; +const LEGACY_DEFAULT_CONSUMER_PROFILE_INSTRUCTIONS = '持续整理会议纪要,重点记录已确认的决策、待办事项(含负责人和截止时间)以及未解决风险;字幕修订时更新已有条目,不重复记录同一事项。'; + +/** 早于「预设目录」模型的按-bot 执行方字段,出现即说明是操作者手工配置过的老配置。 */ const LEGACY_VC_CONSUMER_AGENT_FIELDS = [ 'defaultAgentAppId', 'defaultAgent', @@ -114,12 +39,6 @@ const LEGACY_VC_CONSUMER_AGENT_FIELDS = [ 'agents', ] as const; -const DEFAULT_CONSUMER_PROFILE_GENERATOR_VERSION = 2; -const LEGACY_PROVENANCE_GENERATOR_VERSION = 1; -const DEFAULT_CONSUMER_PROFILE_ID = 'minutes'; -const DEFAULT_CONSUMER_PROFILE_LABEL = '会议纪要'; -const LEGACY_DEFAULT_CONSUMER_PROFILE_INSTRUCTIONS = '持续整理会议纪要,重点记录已确认的决策、待办事项(含负责人和截止时间)以及未解决风险;字幕修订时更新已有条目,不重复记录同一事项。'; -export const DEFAULT_CONSUMER_PROFILE_INSTRUCTIONS = '持续整理会议纪要,重点记录已确认的决策、待办事项(含负责人和截止时间)以及未解决风险;字幕修订时更新已有条目,不重复记录同一事项。仅在出现新的关键决策、明确待办或风险,或被用户点名时,才在监听群输出简洁增量;无实质增量时保持静默,不发送确认或心跳。需要向会议内发送文字或语音时,必须通过 botmux 受管 request-output/action gate 提交,不得绕过权限、所有权与审核策略。'; const LEGACY_DEFAULT_CONSUMER_PROFILE_KEYS = [ 'agentAppId', 'capabilities', @@ -130,77 +49,6 @@ const LEGACY_DEFAULT_CONSUMER_PROFILE_KEYS = [ 'role', ] as const; -export interface VcMeetingDefaultConsumerProfileOwnedConfig { - defaultMode: unknown; - defaultConsumerIds: unknown; - profile: unknown; -} - -/** Hash exactly the fields owned by the default-profile generator. */ -export function computeVcMeetingDefaultConsumerProfileConfigHash( - input: VcMeetingDefaultConsumerProfileOwnedConfig, -): string { - const canonicalOwnedConfig = canonicalJson({ - defaultMode: input.defaultMode, - defaultConsumerIds: input.defaultConsumerIds, - profile: input.profile, - }); - return `sha256:${createHash('sha256').update(canonicalOwnedConfig, 'utf8').digest('hex')}`; -} - -/** - * Verify that the current bootstrap marker still fingerprints the generated - * defaults. Non-generator fields such as enabled/injectIntervalMs are excluded. - */ -export function isVcMeetingDefaultConsumerProfileBootstrapIntact( - meetingConsumer: { - defaultMode?: unknown; - defaultConsumerIds?: unknown; - consumerProfiles?: unknown; - defaultProfileBootstrap?: unknown; - }, -): boolean { - return vcMeetingDefaultConsumerBootstrapProfileForVersion( - meetingConsumer, - DEFAULT_CONSUMER_PROFILE_GENERATOR_VERSION, - ) !== undefined; -} - -function vcMeetingDefaultConsumerBootstrapProfileForVersion( - meetingConsumer: { - defaultMode?: unknown; - defaultConsumerIds?: unknown; - consumerProfiles?: unknown; - defaultProfileBootstrap?: unknown; - }, - generatorVersion: number, -): Record | undefined { - const marker = meetingConsumer.defaultProfileBootstrap; - if (!marker || typeof marker !== 'object' || Array.isArray(marker)) return undefined; - const markerEntry = marker as Record; - if (markerEntry.generatorVersion !== generatorVersion - || typeof markerEntry.profileId !== 'string' - || typeof markerEntry.configHash !== 'string') return undefined; - if (!Array.isArray(meetingConsumer.consumerProfiles)) return undefined; - const matchingProfiles = meetingConsumer.consumerProfiles.filter(profile => - !!profile - && typeof profile === 'object' - && !Array.isArray(profile) - && (profile as Record).id === markerEntry.profileId); - if (matchingProfiles.length !== 1) return undefined; - try { - return markerEntry.configHash === computeVcMeetingDefaultConsumerProfileConfigHash({ - defaultMode: meetingConsumer.defaultMode, - defaultConsumerIds: meetingConsumer.defaultConsumerIds, - profile: matchingProfiles[0], - }) - ? matchingProfiles[0] as Record - : undefined; - } catch { - return undefined; - } -} - function isLegacyGeneratedMinutesProfile(profile: unknown): profile is Record & { agentAppId: string } { if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return false; const entry = profile as Record; @@ -219,56 +67,44 @@ function isLegacyGeneratedMinutesProfile(profile: unknown): profile is Record { - return { - id: DEFAULT_CONSUMER_PROFILE_ID, - agentAppId, - label: DEFAULT_CONSUMER_PROFILE_LABEL, - role: 'minutes', - instructions: DEFAULT_CONSUMER_PROFILE_INSTRUCTIONS, - responseMode: 'listener_thread', - capabilities: [ - 'listener.output.request', - 'meeting.output.request', - 'meeting.read', - ], - ownedSinks: ['meeting_text', 'meeting_voice'], - }; -} - /** - * Upgrade only an untouched, single-profile v1 bootstrap. The single-profile - * requirement matters because the v1 marker intentionally ignored extra - * operator-owned catalog entries; silently adding listener/sink ownership in - * that case could make previously composable selections conflict. + * 这个 bot 的会议消费面是不是**操作者按老模型手工配过执行方策略**。 + * + * 老模型(预设目录之前)用 `agentCandidates` / `defaultAgentAppId` 这类字段直接 + * 点名「哪些 bot 可以当会议 agent」。这些字段一旦出现就是操作者显式意图,共享 + * 目录不去覆盖它——那些 bot 继续按自己配的候选名单跑会中选择卡。 + * + * 注意这与「拉 A 进会却把 B 拉进群」不是一回事:那条 bug 来自机器播种时焊进 + * 预设的 `agentAppId`(操作者从没写过),而候选名单是操作者亲手列的。 */ -function upgradeVcMeetingDefaultConsumerProfileV1( - meetingConsumer: Record, +export function hasLegacyVcMeetingConsumerAgentPolicy( + meetingConsumer: unknown, ): boolean { - if (meetingConsumer.defaultMode !== 'agents' - || !Array.isArray(meetingConsumer.defaultConsumerIds) - || meetingConsumer.defaultConsumerIds.length !== 1 - || meetingConsumer.defaultConsumerIds[0] !== DEFAULT_CONSUMER_PROFILE_ID - || !Array.isArray(meetingConsumer.consumerProfiles) - || meetingConsumer.consumerProfiles.length !== 1) return false; - const profile = vcMeetingDefaultConsumerBootstrapProfileForVersion( - meetingConsumer, - LEGACY_PROVENANCE_GENERATOR_VERSION, + if (!meetingConsumer || typeof meetingConsumer !== 'object' || Array.isArray(meetingConsumer)) return false; + return LEGACY_VC_CONSUMER_AGENT_FIELDS.some( + field => Object.prototype.hasOwnProperty.call(meetingConsumer, field), ); - if (!isLegacyGeneratedMinutesProfile(profile)) return false; +} - const upgradedProfile = createVcMeetingDefaultConsumerProfile(profile.agentAppId); - meetingConsumer.consumerProfiles = [upgradedProfile]; - meetingConsumer.defaultProfileBootstrap = { - generatorVersion: DEFAULT_CONSUMER_PROFILE_GENERATOR_VERSION, - profileId: DEFAULT_CONSUMER_PROFILE_ID, - configHash: computeVcMeetingDefaultConsumerProfileConfigHash({ - defaultMode: 'agents', - defaultConsumerIds: [DEFAULT_CONSUMER_PROFILE_ID], - profile: upgradedProfile, - }), - }; - return true; +/** + * 这份 per-bot 预设是不是**机器播种**出来的(而非操作者写的)。 + * + * 判据是播种时写下的出处标记 `defaultProfileBootstrap` 正好覆盖当前这组预设: + * 只有一条预设、且 id 与出处记录的 `profileId` 一致。操作者后来改过(加了第二条 + * 预设、换了 id)就不再匹配——那时它是操作者内容,必须原样保留。 + * + * 绑定层据此让这些 bot 回到共享目录:机器播种物既会遮蔽操作者的共享预设, + * 里面的 `agentAppId` 还可能指向另一个 bot。判定只发生在读路径,不改磁盘。 + */ +export function isVcMeetingSeededConsumerProfileBlock( + meetingConsumer: Pick | undefined, +): boolean { + const provenance = meetingConsumer?.defaultProfileBootstrap; + if (!provenance) return false; + const profiles = meetingConsumer?.consumerProfiles; + return Array.isArray(profiles) + && profiles.length === 1 + && profiles[0]?.id === provenance.profileId; } /** @@ -290,145 +126,3 @@ export function isLegacyVcMeetingDefaultConsumerSeedCandidate( || consumer.consumerProfiles.length !== 1) return false; return isLegacyGeneratedMinutesProfile(consumer.consumerProfiles[0]); } - -/** - * Mutate one latest raw meetingConsumer object only when no profile or legacy - * agent policy has ever been initialized. Own-property checks are intentional: - * `consumerProfiles: []` is an explicit opt-out and must never be resurrected. - */ -export function seedVcMeetingDefaultConsumerProfile( - meetingConsumer: Record, - listenerBotAppId: string, - agents: readonly VcMeetingConsumerBootstrapAgent[], -): boolean { - // The same lock-scoped mutator handles fresh materialization and provenance- - // fenced v1 upgrades, so daemon boot and Dashboard listener selection cannot - // diverge. Upgrade before the own-property opt-out gates below. - if (upgradeVcMeetingDefaultConsumerProfileV1(meetingConsumer)) return true; - if (Object.prototype.hasOwnProperty.call(meetingConsumer, 'consumerProfiles')) return false; - if (Object.prototype.hasOwnProperty.call(meetingConsumer, 'defaultConsumerIds')) return false; - if (LEGACY_VC_CONSUMER_AGENT_FIELDS.some(field => - Object.prototype.hasOwnProperty.call(meetingConsumer, field))) return false; - // Any explicitly persisted mode is operator-owned state. In particular, - // `defaultMode: listenOnly` must not be mistaken for a fresh install and - // silently changed to agents on upgrade. - if (Object.prototype.hasOwnProperty.call(meetingConsumer, 'defaultMode')) return false; - - const agent = selectVcMeetingDefaultConsumerAgent(listenerBotAppId, agents); - if (!agent) return false; - - const profile = createVcMeetingDefaultConsumerProfile(agent.appId); - const defaultConsumerIds = [DEFAULT_CONSUMER_PROFILE_ID]; - meetingConsumer.consumerProfiles = [profile]; - meetingConsumer.defaultMode = 'agents'; - meetingConsumer.defaultConsumerIds = defaultConsumerIds; - meetingConsumer.defaultProfileBootstrap = { - generatorVersion: DEFAULT_CONSUMER_PROFILE_GENERATOR_VERSION, - profileId: DEFAULT_CONSUMER_PROFILE_ID, - configHash: computeVcMeetingDefaultConsumerProfileConfigHash({ - defaultMode: 'agents', - defaultConsumerIds, - profile, - }), - }; - return true; -} - -export type BootstrapVcMeetingDefaultConsumerProfileResult = - | { ok: true; seeded: true; agentAppId: string } - | { ok: true; seeded: false; reason: 'disabled' | 'already_initialized' | 'legacy_config' | 'no_eligible_agent' } - | { ok: false; reason: 'bot_not_in_config' | 'config_unavailable' | 'validation_failed'; error?: string }; - -/** - * Lock-protected, idempotent one-time materialization used after daemon config - * load. The latest file is parsed again under the lock, so concurrent daemon - * starts and Dashboard saves cannot overwrite or resurrect an explicit empty - * catalog. - */ -export async function bootstrapVcMeetingDefaultConsumerProfile( - listenerBotAppId: string, - deps: VcMeetingConsumerBootstrapAgentDeps = defaultAgentDeps, -): Promise { - try { - const result = await rmwBotEntry( - listenerBotAppId, - (entry, raw) => { - let configs: BotConfig[]; - try { - configs = parseBotConfigsFromText(JSON.stringify(raw)); - } catch (err) { - return { - write: false, - result: { - ok: false, - reason: 'validation_failed', - error: err instanceof Error ? err.message : String(err), - } as const, - }; - } - const bot = configs.find(config => config.larkAppId === listenerBotAppId); - if (!bot) { - return { write: false, result: { ok: false, reason: 'bot_not_in_config' } as const }; - } - const rawEntry = entry && typeof entry === 'object' && !Array.isArray(entry) - ? entry as Record - : {}; - const vcAgent = rawEntry.vcMeetingAgent && typeof rawEntry.vcMeetingAgent === 'object' - && !Array.isArray(rawEntry.vcMeetingAgent) - ? rawEntry.vcMeetingAgent as Record - : undefined; - const consumer = vcAgent?.meetingConsumer && typeof vcAgent.meetingConsumer === 'object' - && !Array.isArray(vcAgent.meetingConsumer) - ? vcAgent.meetingConsumer as Record - : undefined; - if (vcAgent?.enabled !== true || consumer?.enabled !== true) { - return { write: false, result: { ok: true, seeded: false, reason: 'disabled' } as const }; - } - const hadConsumerProfiles = Object.prototype.hasOwnProperty.call(consumer, 'consumerProfiles'); - const hasLegacy = LEGACY_VC_CONSUMER_AGENT_FIELDS.some(field => - Object.prototype.hasOwnProperty.call(consumer, field)) - || consumer.defaultMode === 'agent'; - if (!seedVcMeetingDefaultConsumerProfile( - consumer, - listenerBotAppId, - buildVcMeetingConsumerBootstrapAgents(configs, deps), - )) { - if (hadConsumerProfiles) { - return { write: false, result: { ok: true, seeded: false, reason: 'already_initialized' } as const }; - } - if (hasLegacy - || Object.prototype.hasOwnProperty.call(consumer, 'defaultConsumerIds') - || Object.prototype.hasOwnProperty.call(consumer, 'defaultMode')) { - return { write: false, result: { ok: true, seeded: false, reason: 'legacy_config' } as const }; - } - return { write: false, result: { ok: true, seeded: false, reason: 'no_eligible_agent' } as const }; - } - try { - // Validate the complete latest file, not only the generated fragment. - parseBotConfigsFromText(JSON.stringify(raw)); - } catch (err) { - return { - write: false, - result: { - ok: false, - reason: 'validation_failed', - error: err instanceof Error ? err.message : String(err), - } as const, - }; - } - const profile = (consumer.consumerProfiles as Array<{ agentAppId: string }>)[0]!; - return { - write: true, - result: { ok: true, seeded: true, agentAppId: profile.agentAppId } as const, - }; - }, - ); - return result.ok ? result.result : { ok: false, reason: 'bot_not_in_config' }; - } catch (err) { - return { - ok: false, - reason: 'config_unavailable', - error: err instanceof Error ? err.message : String(err), - }; - } -} diff --git a/src/services/vc-meeting-consumer-profile-store.ts b/src/services/vc-meeting-consumer-profile-store.ts deleted file mode 100644 index 13bd37338..000000000 --- a/src/services/vc-meeting-consumer-profile-store.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { createHash } from 'node:crypto'; -import type { - BotConfig, -} from '../bot-registry.js'; -import { - parseBotConfigsFromText, -} from '../bot-registry.js'; -import type { - VcMeetingConsumerConfig, - VcMeetingConsumerProfileConfig, -} from '../types.js'; -import { canonicalJson } from '../utils/canonical-input-hash.js'; -import { - readRawConfig, - requireConfigPath, - rmwBotEntry, -} from './config-store.js'; -import { isLegacyVcMeetingDefaultConsumerSeedCandidate } from './vc-meeting-consumer-profile-bootstrap.js'; - -export type VcMeetingConsumerProfileFieldError = { - path: string; - message: string; -}; - -export interface VcMeetingConsumerProfilesSnapshot { - listenerBotAppId: string; - revision: string; - /** Distinguishes a never-initialized catalog from an explicit empty profile - * catalog and from the still-supported legacy single-agent policy. */ - catalogState: 'uninitialized' | 'explicit_empty' | 'legacy_or_partial' | 'profiles'; - defaultMode: 'listenOnly' | 'agents'; - defaultConsumerIds: string[]; - profiles: VcMeetingConsumerProfileConfig[]; - defaultProfileBootstrap?: VcMeetingConsumerConfig['defaultProfileBootstrap']; - migrationOffer?: 'enable_seeded_minutes_default'; -} - -export interface UpdateVcMeetingConsumerProfilesInput { - expectedRevision: string; - defaultMode: 'listenOnly' | 'agents'; - defaultConsumerIds: string[]; - profiles: VcMeetingConsumerProfileConfig[]; -} - -export type UpdateVcMeetingConsumerProfilesResult = - | { ok: true; snapshot: VcMeetingConsumerProfilesSnapshot } - | { ok: false; reason: 'bot_not_in_config' | 'config_conflict' | 'validation_failed' | 'config_unavailable'; fieldErrors?: VcMeetingConsumerProfileFieldError[] }; - -function canonicalRevision( - consumer: VcMeetingConsumerConfig | undefined, - rawConsumer: Record | undefined, -): string { - return `sha256:${createHash('sha256') - // Raw own-property presence is semantic for bootstrap/catalog ownership. - // Hash the parsed raw object so a concurrent hand edit such as an empty - // legacy alias cannot normalize away and slip past optimistic concurrency. - .update(canonicalJson(rawConsumer ?? consumer ?? null), 'utf8') - .digest('hex')}`; -} - -function findBot(configs: readonly BotConfig[], larkAppId: string): BotConfig | undefined { - return configs.find(config => config.larkAppId === larkAppId); -} - -function rawBotEntry(raw: readonly unknown[], larkAppId: string): Record | undefined { - return raw.find((value): value is Record => - !!value && typeof value === 'object' && !Array.isArray(value) - && (value as Record).larkAppId === larkAppId); -} - -function rawMeetingConsumer(entry: Record | undefined): Record | undefined { - const vcAgent = entry?.vcMeetingAgent; - if (!vcAgent || typeof vcAgent !== 'object' || Array.isArray(vcAgent)) return undefined; - const consumer = (vcAgent as Record).meetingConsumer; - return consumer && typeof consumer === 'object' && !Array.isArray(consumer) - ? consumer as Record - : undefined; -} - -function catalogStateFromRaw( - entry: Record | undefined, -): VcMeetingConsumerProfilesSnapshot['catalogState'] { - const consumer = rawMeetingConsumer(entry); - if (!consumer) return 'uninitialized'; - if (Object.prototype.hasOwnProperty.call(consumer, 'consumerProfiles')) { - return Array.isArray(consumer.consumerProfiles) && consumer.consumerProfiles.length === 0 - ? 'explicit_empty' - : 'profiles'; - } - const legacyOrPartial = [ - 'defaultAgentAppId', - 'defaultAgent', - 'agentCandidates', - 'agents', - 'defaultConsumerIds', - 'defaultMode', - ].some(field => Object.prototype.hasOwnProperty.call(consumer, field)) - || consumer.defaultMode === 'agent'; - return legacyOrPartial ? 'legacy_or_partial' : 'uninitialized'; -} - -function snapshotFromBot( - bot: BotConfig, - entry?: Record, -): VcMeetingConsumerProfilesSnapshot { - const consumer = bot.vcMeetingAgent?.meetingConsumer; - const rawConsumer = rawMeetingConsumer(entry); - const profiles = consumer?.consumerProfiles ?? []; - const profileIds = new Set(profiles.map(profile => profile.id)); - const defaultConsumerIds = (consumer?.defaultConsumerIds ?? []).filter(id => profileIds.has(id)); - return { - listenerBotAppId: bot.larkAppId, - revision: canonicalRevision(consumer, rawConsumer), - catalogState: catalogStateFromRaw(entry), - defaultMode: consumer?.defaultMode === 'agents' && defaultConsumerIds.length > 0 - ? 'agents' - : 'listenOnly', - defaultConsumerIds, - profiles, - ...(consumer?.defaultProfileBootstrap - ? { defaultProfileBootstrap: consumer.defaultProfileBootstrap } - : {}), - ...(rawConsumer && isLegacyVcMeetingDefaultConsumerSeedCandidate(rawConsumer) - ? { migrationOffer: 'enable_seeded_minutes_default' as const } - : {}), - }; -} - -function parseConfigs(raw: unknown[]): BotConfig[] { - return parseBotConfigsFromText(JSON.stringify(raw)); -} - -function validationError(err: unknown): VcMeetingConsumerProfileFieldError { - const message = err instanceof Error ? err.message : String(err); - const pathMatch = message.match( - /vcMeetingAgent\.meetingConsumer\.(consumerProfiles(?:\[\d+\])?(?:\.[A-Za-z0-9_]+)*(?:\[\d+\])?|defaultConsumerIds(?:\[\d+\])?|defaultMode)/u, - ); - let path = pathMatch?.[1]?.replace(/^consumerProfiles/u, 'profiles'); - if (path) { - path = path - .replace(/\.filter\.activityTypes/u, '.activityTypes') - .replace(/\.(?:capabilities|ownedSinks)(?:\[\d+\])?$/u, '.permissionPreset'); - } else if (/defaultConsumerIds|defaultMode=agents|selected profiles|selectedConsumerIds/u.test(message)) { - path = 'defaultConsumerIds'; - } else { - path = 'profiles'; - } - return { path, message }; -} - -function rawProfile(profile: VcMeetingConsumerProfileConfig): Record { - return { - id: profile.id, - agentAppId: profile.agentAppId, - ...(profile.label ? { label: profile.label } : {}), - role: profile.role, - ...(profile.instructions ? { instructions: profile.instructions } : {}), - ...(profile.filter ? { filter: profile.filter } : {}), - responseMode: profile.responseMode, - ...(profile.listenerDelivery ? { listenerDelivery: profile.listenerDelivery } : {}), - capabilities: [...profile.capabilities], - ...(profile.ownedSinks?.length ? { ownedSinks: [...profile.ownedSinks] } : {}), - }; -} - -function applyProfilesToRawEntry( - entry: Record, - input: UpdateVcMeetingConsumerProfilesInput, -): void { - const vcMeetingAgent = entry.vcMeetingAgent && typeof entry.vcMeetingAgent === 'object' && !Array.isArray(entry.vcMeetingAgent) - ? entry.vcMeetingAgent as Record - : {}; - const meetingConsumer = vcMeetingAgent.meetingConsumer && typeof vcMeetingAgent.meetingConsumer === 'object' && !Array.isArray(vcMeetingAgent.meetingConsumer) - ? vcMeetingAgent.meetingConsumer as Record - : {}; - meetingConsumer.enabled = true; - meetingConsumer.consumerProfiles = input.profiles.map(rawProfile); - meetingConsumer.defaultMode = input.defaultMode; - // Keep the submitted selection byte-for-byte (apart from JSON encoding) until - // the shared bot-registry parser validates it below. Silently filtering - // unknown/duplicate ids or downgrading an empty agents default would let the - // dashboard accept a different policy than the daemon will enforce. - if (input.defaultConsumerIds.length > 0) { - meetingConsumer.defaultConsumerIds = [...input.defaultConsumerIds]; - } - else delete meetingConsumer.defaultConsumerIds; - // An explicit Dashboard/CLI save transfers ownership to the operator. Do - // not leave generator provenance behind: future automatic migrations must - // require both the marker and an unchanged generated fingerprint. - delete meetingConsumer.defaultProfileBootstrap; - // Presence of consumerProfiles is the profile-mode discriminator. Remove - // legacy aliases so they cannot silently revive after an explicit empty save. - delete meetingConsumer.defaultAgentAppId; - delete meetingConsumer.defaultAgent; - delete meetingConsumer.agentCandidates; - delete meetingConsumer.agents; - vcMeetingAgent.meetingConsumer = meetingConsumer; - entry.vcMeetingAgent = vcMeetingAgent; -} - -export async function readVcMeetingConsumerProfiles( - listenerBotAppId: string, -): Promise { - const path = requireConfigPath(); - const raw = await readRawConfig(path); - const bot = findBot(parseConfigs(raw), listenerBotAppId); - return bot ? snapshotFromBot(bot, rawBotEntry(raw, listenerBotAppId)) : undefined; -} - -/** - * Optimistic, lock-protected replacement of the listener bot's preset catalog. - * The expected revision is derived from the latest canonical on-disk config, - * so hand edits and concurrent dashboard tabs cannot overwrite one another. - */ -export async function updateVcMeetingConsumerProfiles( - listenerBotAppId: string, - input: UpdateVcMeetingConsumerProfilesInput, -): Promise { - try { - const result = await rmwBotEntry(listenerBotAppId, (entry, raw) => { - let current: VcMeetingConsumerProfilesSnapshot; - try { - const bot = findBot(parseConfigs(raw), listenerBotAppId); - if (!bot) return { write: false, result: { ok: false, reason: 'bot_not_in_config' } }; - current = snapshotFromBot(bot, rawBotEntry(raw, listenerBotAppId)); - } catch (err) { - return { - write: false, - result: { ok: false, reason: 'validation_failed', fieldErrors: [validationError(err)] }, - }; - } - if (current.revision !== input.expectedRevision) { - return { write: false, result: { ok: false, reason: 'config_conflict' } }; - } - applyProfilesToRawEntry(entry as Record, input); - let updated: VcMeetingConsumerProfilesSnapshot; - try { - const bot = findBot(parseConfigs(raw), listenerBotAppId); - if (!bot) return { write: false, result: { ok: false, reason: 'bot_not_in_config' } }; - updated = snapshotFromBot(bot, rawBotEntry(raw, listenerBotAppId)); - } catch (err) { - return { - write: false, - result: { ok: false, reason: 'validation_failed', fieldErrors: [validationError(err)] }, - }; - } - return { write: true, result: { ok: true, snapshot: updated } }; - }); - return result.ok ? result.result : { ok: false, reason: 'bot_not_in_config' }; - } catch { - return { ok: false, reason: 'config_unavailable' }; - } -} diff --git a/src/services/vc-meeting-consumer-profile-templates.ts b/src/services/vc-meeting-consumer-profile-templates.ts index 7d5c1fd78..3e174f92d 100644 --- a/src/services/vc-meeting-consumer-profile-templates.ts +++ b/src/services/vc-meeting-consumer-profile-templates.ts @@ -85,7 +85,7 @@ export const VC_MEETING_CONSUMER_PROFILE_TEMPLATE_CATALOG: VcMeetingConsumerProf }, { templateId: 'meeting-facilitator', - version: 1, + version: 2, source: 'builtin', title: { zh: '会议主持', en: 'Meeting facilitator' }, description: { @@ -95,8 +95,8 @@ export const VC_MEETING_CONSUMER_PROFILE_TEMPLATE_CATALOG: VcMeetingConsumerProf suggestedProfileId: 'facilitator', profileLabel: { zh: '会议主持', en: 'Meeting facilitator' }, instructions: { - zh: '你是会议主持人。优先读取“本次会议补充说明”中的议程、目标和时间安排;若缺失,则从标题和开场讨论推断,并在必要时向参会者确认。按议程推进环节,明确每一环节目标,识别跑题或阻塞,邀请需要的人回应,并在阶段切换时总结结论、分歧和待办。仅在确有主持价值时,通过受管的会中文字或语音输出提出简短问题、提醒或阶段总结,避免频繁打断;不得越过输出权限与审核门禁,不得替参会者作决定。', - en: 'Act as the meeting facilitator. First use the agenda, goals, and timing from the per-meeting context. If they are missing, infer cautiously from the title and opening discussion and confirm with participants when needed. Move through agenda sections, clarify each section goal, detect digressions or blockers, invite the right people to respond, and recap decisions, disagreements, and actions at transitions. Only when facilitation adds clear value, request a brief question, reminder, or recap through managed in-meeting text or voice output. Avoid frequent interruptions, never bypass output permissions or approval gates, and never decide on behalf of participants.', + zh: '你是会议主持人。优先读取“本次会议补充说明”中的议程、目标和时间安排;若缺失,则从标题和开场讨论推断,并在必要时向参会者确认。按议程推进环节,明确每一环节目标,识别跑题或阻塞,邀请需要的人回应,并在阶段切换时总结结论、分歧和待办。参会者点名你、向你提问或明确请你发言时,必须立即通过受管的会中文字或语音输出回应——直接请求优先于“是否有主持价值”的自行判断;若发送被权限或审核门禁拒绝,不要沉默着放弃,在下一次可发言时说明原因。其余场合仅在确有主持价值时输出简短问题、提醒或阶段总结,避免频繁打断;不得越过输出权限与审核门禁,不得替参会者作决定。', + en: 'Act as the meeting facilitator. First use the agenda, goals, and timing from the per-meeting context. If they are missing, infer cautiously from the title and opening discussion and confirm with participants when needed. Move through agenda sections, clarify each section goal, detect digressions or blockers, invite the right people to respond, and recap decisions, disagreements, and actions at transitions. When a participant addresses you directly, asks you a question, or explicitly asks you to speak, respond immediately through managed in-meeting text or voice output — a direct request overrides your own value-of-speaking judgment; if the send is refused by a permission or approval gate, do not silently give up: explain why at the next opportunity to speak. Otherwise, only when facilitation adds clear value, request a brief question, reminder, or recap. Avoid frequent interruptions, never bypass output permissions or approval gates, and never decide on behalf of participants.', }, activityTypes: ['transcript_received', 'chat_received', 'participant_joined', 'participant_left'], responseMode: 'silent', diff --git a/src/services/vc-meeting-delivery-store.ts b/src/services/vc-meeting-delivery-store.ts index b05cd6997..570949b02 100644 --- a/src/services/vc-meeting-delivery-store.ts +++ b/src/services/vc-meeting-delivery-store.ts @@ -1350,6 +1350,59 @@ export function listActiveVcMeetingDeliveriesForSession( || a.receipt.deliveryKey.localeCompare(b.receipt.deliveryKey)); } +/** + * Most-recent authorizable delivery receipt for a receiver session, INCLUDING a + * just-`completed` one. Unlike listActiveVcMeetingDeliveriesForSession (which + * skips terminal receipts), this returns the latest `dispatched` OR `completed` + * receipt by updatedAt. + * + * Why it exists: the in-meeting managed-output CLI (request-output) normally + * carries the LIVE turn's origin (turnId/dispatchAttempt) from the process-tree + * marker. But a meeting agent frequently decides to speak AFTER the delivery + * turn has gone idle — at which point the live marker's turn fields are cleared, + * so the CLI has no origin to send and the daemon's durable authorization + * fallback cannot fire. This lets the CLI recover the delivery identity of the + * turn it just processed from the durable ledger (keyed only by sessionId), so + * the daemon can re-authorize in-meeting output against the completed receipt + * (evaluateVcMeetingManagedSend with allowTerminalReceipt + forInMeetingOutput). + * Read-only; failed/abandoned/ambiguous receipts are never returned. + */ +export function latestVcMeetingDeliveryForSession( + dataDir: string, + receiverSessionId: string, +): VcMeetingDeliveryLookupResult | undefined { + if (!receiverSessionId.trim()) return undefined; + const dir = join(dataDir, DIR_NAME); + if (!existsSync(dir)) return undefined; + + let best: VcMeetingDeliveryLookupResult | undefined; + for (const name of readdirSync(dir)) { + if (!name.endsWith('.json')) continue; + const state = readStateFile(join(dir, name)); + if (!state) continue; + for (const stream of Object.values(state.streams)) { + if (stream.receiverSessionId !== receiverSessionId) continue; + for (const receipt of Object.values(stream.receipts)) { + // Only receipts the daemon could still authorize in-meeting output for. + if (receipt.status !== 'dispatched' && receipt.status !== 'completed') continue; + if (best && receipt.updatedAt <= best.receipt.updatedAt) continue; + best = { + memberKey: { + listenerAppId: stream.listenerAppId, + meetingId: stream.meetingId, + memberId: stream.memberId, + memberEpoch: stream.memberEpoch, + }, + receiverSessionId: stream.receiverSessionId, + receipt, + receiverCommittedThrough: stream.receiverCommittedThrough, + }; + } + } + } + return best; +} + /** * 按 receiverSessionId 全局反查「当前有效」的 membership projection—— * status === 'active' 且 memberEpoch 是该 member 的最新 epoch(换代后旧 epoch diff --git a/src/services/vc-meeting-send-policy.ts b/src/services/vc-meeting-send-policy.ts index 155252212..be53ed5ac 100644 --- a/src/services/vc-meeting-send-policy.ts +++ b/src/services/vc-meeting-send-policy.ts @@ -36,6 +36,17 @@ export interface VcMeetingManagedSendOrigin { /** Worker UI may need to patch/freeze an already-created card after terminal; * new botmux send/ask effects must leave this false. */ allowTerminalReceipt?: boolean; + /** In-meeting managed output (request-output → managed-action) is a DIFFERENT + * channel from listener-group auto-post. `responseMode: silent` governs only + * whether the agent auto-posts to the listener group; it must NOT gate + * in-meeting speech, which is authorized downstream at the hub by + * capability + textOutputPolicy/voiceOutputPolicy (see vc-meeting-action-gate). + * Set true ONLY on the in-meeting-output authorization path so this evaluator + * still proves receipt identity/liveness (existence, attempt match, active + * projection, dispatched/completed status) but skips the silent veto. Listener + * auto-post callers (final_output / botmux send) leave it false so silent keeps + * suppressing group posts. */ + forInMeetingOutput?: boolean; } export type VcMeetingManagedSendDecision = @@ -241,7 +252,16 @@ export function evaluateVcMeetingManagedSend( // The policy is frozen on the receipt. Reading the current projection here // would let a later silent→listener_thread update retroactively authorize an // old silent attempt. Missing mode is an old WIP record and fails closed. - if ((lookup.receipt.responseMode ?? 'silent') === 'silent') { + // + // `responseMode: silent` governs ONLY the listener-group auto-post channel. The + // in-meeting managed-output channel (request-output → hub managed-action) is a + // separate channel authorized by capability + textOutputPolicy/voiceOutputPolicy + // at the hub, and must not be blocked by silent — a "silent in the listener + // group" facilitator still speaks in the meeting. So skip this veto when the + // caller is the in-meeting-output path (forInMeetingOutput); every other caller + // (final_output / botmux send to the listener group) keeps the suppression. + if (!origin.forInMeetingOutput + && (lookup.receipt.responseMode ?? 'silent') === 'silent') { return { ok: false, errorCode: 'silent_delivery', error: 'managed output is disabled for this silent delivery' }; } return { diff --git a/src/services/vc-meeting-shared-consumer-catalog-store.ts b/src/services/vc-meeting-shared-consumer-catalog-store.ts new file mode 100644 index 000000000..6d0df6586 --- /dev/null +++ b/src/services/vc-meeting-shared-consumer-catalog-store.ts @@ -0,0 +1,283 @@ +/** + * 全 fleet 共享会议角色预设目录的读写层(`~/.botmux/config.json` 的 + * `vcMeetingAgent.consumerCatalog`)。 + * + * 沿用退役的 per-bot store 那套契约(乐观并发 revision + 字段级错误路径), + * 差别只有两点: + * + * - 存的是**一份**目录,不再有 `listenerBotAppId` 维度; + * - 预设条目**没有** `agentAppId`:执行方是「被拉进这场会议的那个 bot」, + * 在读路径合并时才绑定(见 vc-meeting-shared-consumer-catalog.ts)。 + * + * 校验借用 bot-registry 的权威 normalizer/resolver:把目录绑到一个占位 appId 上 + * 走一遍真实校验,这样 Dashboard 能接受的东西 daemon 一定也能解析。 + */ +import { createHash } from 'node:crypto'; +import { + normalizeVcMeetingConsumerProfiles, + resolveVcMeetingConsumerProfiles, +} from '../bot-registry.js'; +import { + globalConfigPath, + rawGlobalVcMeetingSharedConsumerCatalog, + writeGlobalVcMeetingSharedConsumerCatalog, + type VcMeetingSharedConsumerCatalog, + type VcMeetingSharedConsumerProfile, +} from '../global-config.js'; +import type { VcMeetingConsumerProfileConfig } from '../types.js'; +import { canonicalJson } from '../utils/canonical-input-hash.js'; + +export type VcMeetingSharedConsumerCatalogFieldError = { + path: string; + message: string; +}; + +export interface VcMeetingSharedConsumerCatalogSnapshot { + revision: string; + /** 区分「从没配置过」「显式清空」「有预设」三态,供 UI 决定空态文案。 */ + catalogState: 'uninitialized' | 'explicit_empty' | 'profiles'; + defaultMode: 'listenOnly' | 'agents'; + defaultConsumerIds: string[]; + profiles: VcMeetingSharedConsumerProfile[]; +} + +export interface UpdateVcMeetingSharedConsumerCatalogInput { + expectedRevision: string; + defaultMode: 'listenOnly' | 'agents'; + defaultConsumerIds: string[]; + profiles: VcMeetingSharedConsumerProfile[]; +} + +export type UpdateVcMeetingSharedConsumerCatalogResult = + | { ok: true; snapshot: VcMeetingSharedConsumerCatalogSnapshot } + | { + ok: false; + reason: 'config_conflict' | 'validation_failed' | 'config_unavailable'; + fieldErrors?: VcMeetingSharedConsumerCatalogFieldError[]; + error?: string; + }; + +/** + * 校验时用的占位执行方。目录本身不带 `agentAppId`,但 bot-registry 的校验需要一个 + * ——用同一个占位值绑定所有条目,正好复现运行时「所有预设都属于同一个 bot」的 + * 事实,于是「同时选中两个角色」在这里就会被 resolver 拦下。 + */ +const CATALOG_PROBE_AGENT_APP_ID = 'cli_shared_catalog_probe'; + +/** + * 从没配置过共享目录时用的内置默认角色。 + * + * 之所以做在**读路径**而不是在 daemon 启动时写一份进配置:`mergeGlobalConfig` + * 没有跨进程锁,fleet 里几十个 daemon 同时启动各写一次,丢的可能是别的顶层 + * 配置项。读路径内置则零写盘、幂等,且 Dashboard 与 daemon 读的是同一个常量, + * 页面上看到的就是每个 bot 进会后真正会跑的角色。 + * + * 注意与「显式清空」的区别:保存过 `profiles: []` 是持久的「所有 bot 都不要 + * 角色」,不会被这份内置默认顶掉(见 catalogStateFromRaw 的三态)。 + */ +export const BUILTIN_VC_MEETING_SHARED_CONSUMER_CATALOG: VcMeetingSharedConsumerCatalog = Object.freeze({ + profiles: [Object.freeze({ + id: 'minutes', + label: '会议纪要', + role: 'minutes', + instructions: '持续整理会议纪要,重点记录已确认的决策、待办事项(含负责人和截止时间)以及未解决风险;字幕修订时更新已有条目,不重复记录同一事项。仅在出现新的关键决策、明确待办或风险,或被用户点名时,才在监听群输出简洁增量;无实质增量时保持静默,不发送确认或心跳。需要向会议内发送文字或语音时,必须通过 botmux 受管 request-output/action gate 提交,不得绕过权限、所有权与审核策略。', + responseMode: 'listener_thread', + capabilities: Object.freeze(['listener.output.request', 'meeting.output.request', 'meeting.read']), + ownedSinks: Object.freeze(['meeting_text', 'meeting_voice']), + })], + defaultMode: 'agents', + defaultConsumerIds: Object.freeze(['minutes']), +}) as VcMeetingSharedConsumerCatalog; + +function canonicalRevision(rawCatalog: unknown): string { + return `sha256:${createHash('sha256') + // 哈希的是**原始**对象而不是归一化结果:手改配置即使被 forgiving 读路径 + // 归一化掉,也必须让 revision 变化,否则会绕过乐观并发。 + .update(canonicalJson(rawCatalog ?? null), 'utf8') + .digest('hex')}`; +} + +function catalogStateFromRaw(rawCatalog: unknown): VcMeetingSharedConsumerCatalogSnapshot['catalogState'] { + if (!rawCatalog || typeof rawCatalog !== 'object' || Array.isArray(rawCatalog)) return 'uninitialized'; + const profiles = (rawCatalog as Record).profiles; + if (!Array.isArray(profiles)) return 'uninitialized'; + return profiles.length === 0 ? 'explicit_empty' : 'profiles'; +} + +/** 把 bot-registry 的错误路径映射成 DTO 路径(与 per-bot store 保持一致)。 */ +function validationError(err: unknown): VcMeetingSharedConsumerCatalogFieldError { + const message = err instanceof Error ? err.message : String(err); + const pathMatch = message.match( + /(?:vcMeetingAgent\.meetingConsumer\.)?(consumerProfiles(?:\[\d+\])?(?:\.[A-Za-z0-9_]+)*(?:\[\d+\])?|defaultConsumerIds(?:\[\d+\])?|defaultMode)/u, + ); + let path = pathMatch?.[1]?.replace(/^consumerProfiles/u, 'profiles'); + if (path) { + path = path + .replace(/\.filter\.activityTypes/u, '.activityTypes') + .replace(/\.(?:capabilities|ownedSinks)(?:\[\d+\])?$/u, '.permissionPreset'); + } else if (/defaultConsumerIds|defaultMode=agents|selected profiles|selectedConsumerIds/u.test(message)) { + path = 'defaultConsumerIds'; + } else { + path = 'profiles'; + } + return { path, message }; +} + +function bindToProbe(profile: VcMeetingSharedConsumerProfile): VcMeetingConsumerProfileConfig { + return { ...profile, agentAppId: CATALOG_PROBE_AGENT_APP_ID }; +} + +/** + * 逐条归一化:坏条目只丢自己,不连累整份目录。 + * + * `normalizeVcMeetingConsumerProfiles` 见到第一条坏数据就抛,整个数组一起没。 + * 对**全局共享**目录来说这个失败面太大:手改配置时一个字段写错,全 fleet 的 + * bot 会一起变成干听。逐条走一遍,坏的那条消失、好的照常生效——保存路径仍走 + * 严格校验({@link validateVcMeetingSharedConsumerCatalog}),所以坏数据只会 + * 从磁盘里来,且一保存就会被拦下并给出字段级错误。 + * + * 跨条目的检查(重复 id 等)在 resolver 而不在 normalizer 里,所以逐条归一化 + * 不会漏掉任何单条校验。 + */ +export function normalizeVcMeetingConsumerProfilesForgiving( + rawProfiles: readonly unknown[], +): { profiles: VcMeetingConsumerProfileConfig[]; errors: string[] } { + const profiles: VcMeetingConsumerProfileConfig[] = []; + const errors: string[] = []; + rawProfiles.forEach((raw, index) => { + try { + profiles.push(...normalizeVcMeetingConsumerProfiles([raw])); + } catch (err) { + // 单条归一化时索引恒为 0,这里补回它在目录里的真实位置。 + errors.push(`profiles[${index}]: ${err instanceof Error ? err.message : String(err)}`); + } + }); + return { profiles, errors }; +} + +function stripAgentAppId(profile: VcMeetingConsumerProfileConfig): VcMeetingSharedConsumerProfile { + const { agentAppId: _agentAppId, ...rest } = profile; + return rest; +} + +/** 目录条目的持久化形状。刻意不写 `agentAppId`。 */ +function rawProfile(profile: VcMeetingSharedConsumerProfile): VcMeetingSharedConsumerProfile { + return { + id: profile.id, + ...(profile.label ? { label: profile.label } : {}), + role: profile.role, + ...(profile.instructions ? { instructions: profile.instructions } : {}), + ...(profile.filter ? { filter: profile.filter } : {}), + responseMode: profile.responseMode, + ...(profile.listenerDelivery ? { listenerDelivery: profile.listenerDelivery } : {}), + capabilities: [...profile.capabilities], + ...(profile.ownedSinks?.length ? { ownedSinks: [...profile.ownedSinks] } : {}), + }; +} + +/** + * 用 bot-registry 的权威校验跑一遍目录。返回归一化后的条目,或字段级错误。 + */ +export function validateVcMeetingSharedConsumerCatalog( + input: Pick, +): { ok: true; profiles: VcMeetingSharedConsumerProfile[] } + | { ok: false; fieldErrors: VcMeetingSharedConsumerCatalogFieldError[] } { + // 一个 bot 同时只能跑一个角色(所有预设的执行方都是它自己,resolver 拒绝两条 + // 被选中的预设共用 agentAppId)。先在这里给出人话,不然用户看到的是 + // "selected profiles X and Y share agentAppId ..." 这种内部措辞。 + if (input.defaultConsumerIds.length > 1) { + return { + ok: false, + fieldErrors: [{ + path: 'defaultConsumerIds', + message: '同一时间只能有一个默认角色:每个 bot 进会后只跑一个角色,请只勾选一个。', + }], + }; + } + let normalized: VcMeetingConsumerProfileConfig[]; + try { + normalized = normalizeVcMeetingConsumerProfiles(input.profiles.map(bindToProbe)); + } catch (err) { + return { ok: false, fieldErrors: [validationError(err)] }; + } + const resolution = resolveVcMeetingConsumerProfiles({ + consumerProfiles: normalized, + defaultConsumerIds: input.defaultConsumerIds, + defaultMode: input.defaultMode, + }); + if (!resolution.ok) { + return { ok: false, fieldErrors: resolution.errors.map(error => validationError(new Error(error))) }; + } + return { ok: true, profiles: normalized.map(stripAgentAppId) }; +} + +function snapshotFromRaw(rawCatalog: unknown): VcMeetingSharedConsumerCatalogSnapshot { + const state = catalogStateFromRaw(rawCatalog); + // 没配置过就落到内置默认目录(daemon 侧绑定层用同一个常量),显式清空则保持空。 + const entry = (state === 'uninitialized' + ? BUILTIN_VC_MEETING_SHARED_CONSUMER_CATALOG + : rawCatalog) as Record; + const rawProfiles = Array.isArray(entry?.profiles) ? entry!.profiles : []; + // 读路径 forgiving:坏条目在 UI 里表现为「这条不见了」而不是整页打不开, + // 保存时才会被上面的权威校验拦住。 + const profiles = normalizeVcMeetingConsumerProfilesForgiving( + rawProfiles.map(profile => ({ + ...(profile && typeof profile === 'object' && !Array.isArray(profile) ? profile : {}), + agentAppId: CATALOG_PROBE_AGENT_APP_ID, + })), + ).profiles.map(stripAgentAppId); + const profileIds = new Set(profiles.map(profile => profile.id)); + const defaultConsumerIds = (Array.isArray(entry?.defaultConsumerIds) ? entry!.defaultConsumerIds : []) + .filter((id): id is string => typeof id === 'string' && profileIds.has(id)); + return { + revision: canonicalRevision(rawCatalog), + catalogState: state, + defaultMode: entry?.defaultMode === 'agents' && defaultConsumerIds.length > 0 ? 'agents' : 'listenOnly', + defaultConsumerIds, + profiles, + }; +} + +export function readVcMeetingSharedConsumerCatalogSnapshot(): VcMeetingSharedConsumerCatalogSnapshot { + return snapshotFromRaw(rawGlobalVcMeetingSharedConsumerCatalog()); +} + +/** + * 乐观并发替换共享目录。 + * + * revision 校验与写入之间没有 `await`,所以同进程内不存在竞态;跨进程用的是 + * `mergeGlobalConfig` 的 tmp+rename 原子写(与其它全局设置一致)。 + */ +export function updateVcMeetingSharedConsumerCatalog( + input: UpdateVcMeetingSharedConsumerCatalogInput, +): UpdateVcMeetingSharedConsumerCatalogResult { + let current: VcMeetingSharedConsumerCatalogSnapshot; + try { + current = readVcMeetingSharedConsumerCatalogSnapshot(); + } catch (err) { + return { ok: false, reason: 'config_unavailable', error: err instanceof Error ? err.message : String(err) }; + } + if (current.revision !== input.expectedRevision) return { ok: false, reason: 'config_conflict' }; + + const validated = validateVcMeetingSharedConsumerCatalog(input); + if (!validated.ok) return { ok: false, reason: 'validation_failed', fieldErrors: validated.fieldErrors }; + + const catalog: VcMeetingSharedConsumerCatalog = { + profiles: validated.profiles.map(rawProfile), + defaultMode: input.defaultConsumerIds.length > 0 ? input.defaultMode : 'listenOnly', + defaultConsumerIds: [...input.defaultConsumerIds], + }; + try { + // mergeGlobalConfig 写完会清掉 readCache 与 vcMeetingAgentLiveCache,所以 + // 下面的回读一定看到新目录,本进程无需另行失效缓存。 + writeGlobalVcMeetingSharedConsumerCatalog(catalog); + } catch (err) { + return { ok: false, reason: 'config_unavailable', error: err instanceof Error ? err.message : String(err) }; + } + return { ok: true, snapshot: readVcMeetingSharedConsumerCatalogSnapshot() }; +} + +/** 便于测试/诊断:当前生效的配置文件路径。 */ +export function vcMeetingSharedConsumerCatalogConfigPath(): string { + return globalConfigPath(); +} diff --git a/src/services/vc-meeting-shared-consumer-catalog.ts b/src/services/vc-meeting-shared-consumer-catalog.ts new file mode 100644 index 000000000..39e46f09f --- /dev/null +++ b/src/services/vc-meeting-shared-consumer-catalog.ts @@ -0,0 +1,197 @@ +/** + * 会议角色预设 → per-bot 有效配置的绑定层。 + * + * 背景:角色预设过去存在每个 bot 自己的 + * `vcMeetingAgent.meetingConsumer.consumerProfiles` 下,并把执行方 `agentAppId` + * 写死进每条预设。这带来两个用户可见的坏行为: + * + * 1. 没配过预设的 bot 被拉进会议时一个角色都选不到; + * 2. 预设里写死的 `agentAppId` 可能指向**另一个** bot(bootstrap 在当前 bot + * 结构上不合格时会静默兜底换人),于是「拉 A 进会 → B 被拉进监听群」。 + * + * 现在的不变量:**没有自己预设的 bot 继承 fleet 共享目录** + * (`~/.botmux/config.json` 的 `vcMeetingAgent.consumerCatalog`),且共享目录里的 + * 条目从类型上就**没有** `agentAppId`——执行方在这一层绑定为「收到这场会议事件 + * 的那个 bot 自己」,于是「拉错 bot」在共享目录里不可表达。共享目录也没配过时用 + * 内置默认目录,所以任何 bot 被拉进会都直接有角色可跑,不需要先去 Dashboard 配。 + * + * 操作者显式配置永远优先,共享目录只在该 bot 什么都没配时生效: + * + * - per-bot `consumerProfiles`(显式空数组 = 「这个 bot 不要任何角色」):原样 + * 沿用,**包括**里面的 `agentAppId`——操作者可以有意把不同角色分给不同 bot + * 做多 agent 分工,读路径不能替他改主意; + * - 老模型的执行方策略(`agentCandidates` 等),那些 bot 继续走候选名单卡。 + * + * 唯一的例外是**机器播种残留**(v2 带 `defaultProfileBootstrap` 出处标记,v1 按 + * 逐字段精确形状识别):它不是操作者意图,读路径上直接忽略,让这些 bot 也回到 + * 共享目录——上面第 2 条坏行为正是这么来的(播种把另一个 bot 的 appId 焊进了 + * 预设),忽略掉它就等于在读路径上修好,磁盘残留原样留着,零迁移写盘。 + */ +import { + globalVcMeetingSharedConsumerCatalog, + type VcMeetingSharedConsumerCatalog, +} from '../global-config.js'; +import type { VcMeetingAgentConfig } from '../bot-registry.js'; +import type { VcMeetingConsumerProfileConfig } from '../types.js'; +import { logger } from '../utils/logger.js'; +import { + hasLegacyVcMeetingConsumerAgentPolicy, + isLegacyVcMeetingDefaultConsumerSeedCandidate, + isVcMeetingSeededConsumerProfileBlock, +} from './vc-meeting-consumer-profile-bootstrap.js'; +import { + BUILTIN_VC_MEETING_SHARED_CONSUMER_CATALOG, + normalizeVcMeetingConsumerProfilesForgiving, +} from './vc-meeting-shared-consumer-catalog-store.js'; + +let warnedCatalogError: string | undefined; + +/** + * 校验一份「已绑定到某 bot」的预设数组。共享目录是全局的,一份坏目录不能让整个 + * fleet 的会议能力一起挂——坏条目逐条丢弃(与 Dashboard 读到的目录保持一致, + * 否则页面上列着的角色 daemon 侧一个都不跑),并告警。每种错误只告警一次, + * 避免每个会议事件刷屏。 + */ +function validateBoundProfiles( + profiles: VcMeetingConsumerProfileConfig[], +): VcMeetingConsumerProfileConfig[] { + const result = normalizeVcMeetingConsumerProfilesForgiving(profiles); + if (result.errors.length > 0) { + const message = result.errors.join('; '); + if (warnedCatalogError !== message) { + warnedCatalogError = message; + logger.warn( + `[vc-agent] shared consumer catalog has invalid entries, they were ignored: ${message}. ` + + 'Fix vcMeetingAgent.consumerCatalog in ~/.botmux/config.json.', + ); + } + } + return result.profiles; +} + +/** 测试用:清掉「同一条错误只告警一次」的去重状态。 */ +export function __resetSharedConsumerCatalogWarnState(): void { + warnedCatalogError = undefined; +} + +export interface BindVcMeetingConsumerCatalogDeps { + /** + * 便于测试注入;默认读全局配置。 + * + * `undefined` = 「从没配置过」,会落到内置默认目录(与 Dashboard 读到的是同一 + * 个常量)。要表达「所有 bot 都不要角色」请返回 `{ profiles: [], ... }`。 + */ + readCatalog?: () => VcMeetingSharedConsumerCatalog | undefined; +} + +/** + * 这份 per-bot 预设是不是**机器播种**出来的(两代播种物都算)。 + * + * v2 播种写了 `defaultProfileBootstrap` 出处标记;v1 没有标记,只能按逐字段精确 + * 形状识别(7 个字段逐字匹配,差一点就当操作者内容保留)。两代都会把执行方 + * `agentAppId` 焊进预设,也都可能焊的是另一个 bot——「拉 A 进会却把 B 拉进群」 + * 就是这么来的,所以两代都要让路给共享目录。 + */ +function isSeededConsumerProfileBlock(meetingConsumer: VcMeetingAgentConfig['meetingConsumer']): boolean { + return isVcMeetingSeededConsumerProfileBlock(meetingConsumer) + || isLegacyVcMeetingDefaultConsumerSeedCandidate(meetingConsumer); +} + +/** 把预设的执行方钉死为 `botAppId` 本人。 */ +function bindToBot( + profile: Omit & { agentAppId?: string }, + botAppId: string, +): VcMeetingConsumerProfileConfig { + return { ...profile, agentAppId: botAppId } as VcMeetingConsumerProfileConfig; +} + +/** + * 把会议角色预设绑定到某个 bot 的有效 VC 配置上。 + * + * - bot 有操作者写的 `consumerProfiles` / 老模型执行方策略 → 原样返回 + * - bot 只有机器播种残留,或什么都没配 → 继承共享目录,`agentAppId` = `botAppId` + * - 共享目录显式清空 / 校验不过 → 原样返回(没配过则用内置默认目录) + * + * 纯函数,不改入参。 + */ +export function bindVcMeetingConsumerCatalogToBot( + botAppId: string, + cfg: VcMeetingAgentConfig, + deps: BindVcMeetingConsumerCatalogDeps = {}, +): VcMeetingAgentConfig { + if (!botAppId) return cfg; + + // 操作者按老模型手写过执行方策略(agentCandidates / defaultAgentAppId ...): + // 那是显式的「就用我点名的这些 bot」,共享目录不去覆盖,会中仍走候选名单卡。 + if (hasLegacyVcMeetingConsumerAgentPolicy(cfg.meetingConsumer)) return cfg; + + // 操作者自己写的 per-bot 预设原样沿用(显式空数组 = 「这个 bot 不要任何角色」), + // 里面的 agentAppId 也不动:把不同角色分给不同 bot 是操作者可以有意做的分工。 + // + // 例外是历史播种残留:它不是操作者意图,既会永久遮蔽共享目录,里面的 agentAppId + // 还可能指向另一个 bot。读路径直接当作「没有 per-bot 预设」走共享目录,磁盘残留 + // 原样留着,零迁移写盘。两代播种物都要认:v2 带 `defaultProfileBootstrap` 出处 + // 标记,v1(更早的版本升上来的)没有标记,只能按逐字段精确形状识别。 + const seeded = isSeededConsumerProfileBlock(cfg.meetingConsumer); + if (!seeded && cfg.meetingConsumer?.consumerProfiles !== undefined) return cfg; + + // 共享目录不可用时的退回值。**关键**:如果 cfg 带的是机器播种残留(seeded),它里面 + // 焊死的 agentAppId 可能指向**另一个** bot——正是本 PR 要消灭的「拉 A 拉 B」。所以 + // seeded 情形绝不能原样 `return cfg`(那等于让 foreign appId 从 fallback 复活),必须 + // 把这些播种预设剥掉、退回「仅监听」。非 seeded(操作者内容/无 per-bot 预设)原样返回。 + const fallbackCfg: VcMeetingAgentConfig = seeded + ? { + ...cfg, + meetingConsumer: { + ...cfg.meetingConsumer, + consumerProfiles: [], + defaultMode: 'listenOnly' as const, + defaultConsumerIds: [], + }, + } + : cfg; + + // 没配过共享目录 → 内置默认目录(一个「会议纪要」角色)。否则「装好就能用」不 + // 成立:43/47 个 bot 从来没有过 vcMeetingAgent 配置,被拉进会只能干听。 + // 显式清空(profiles: [])是持久的「都不要」,不会被内置默认顶掉。 + const catalog = (deps.readCatalog ?? globalVcMeetingSharedConsumerCatalog)() + ?? BUILTIN_VC_MEETING_SHARED_CONSUMER_CATALOG; + if (catalog.profiles.length === 0) return fallbackCfg; + + const bound = validateBoundProfiles( + catalog.profiles.map(profile => bindToBot(profile, botAppId)), + ); + // 整份目录都是坏条目时退回 fallbackCfg(seeded 已被中和成仅监听),而不是把这个 + // bot 的会议消费面配成空数组(空数组是「这个 bot 不要任何角色」的显式语义,坏数据 + // 不该表达它;seeded 焊死的 foreign appId 更不能从这里复活)。 + if (bound.length === 0) return fallbackCfg; + + const profileIds = new Set(bound.map(profile => profile.id)); + // per-bot 默认预设覆盖:这个 bot 若自己挑了 meetingConsumer.catalogDefaultConsumerId + // (从共享目录的角色里挑一个),就用它作为默认角色——这是显式的「这个 bot 就跑这个 + // 角色」意图,即便共享目录全局默认是仅监听也照跑。没挑(或挑了目录里不存在的 id)则 + // 回落共享目录的全局默认(defaultMode + defaultConsumerIds 都跟随全局)。 + const perBotDefaultId = cfg.meetingConsumer?.catalogDefaultConsumerId; + const hasPerBotOverride = typeof perBotDefaultId === 'string' && profileIds.has(perBotDefaultId); + // 一个 bot 同时只跑一个角色:bot-registry 的 resolver 拒绝两条被选中的预设 + // 共用同一个 agentAppId,而绑定后所有预设的 agentAppId 都是这个 bot。取第一个 + // 命中的默认角色,其余留在目录里供会中卡片切换。 + const globalSelected = catalog.defaultMode === 'agents' + ? catalog.defaultConsumerIds.filter(id => profileIds.has(id)).slice(0, 1) + : []; + const selected = hasPerBotOverride ? [perBotDefaultId] : globalSelected; + return { + ...cfg, + meetingConsumer: { + ...cfg.meetingConsumer, + // 有共享目录就意味着操作者已经配置过会议消费面;不再要求每个 bot 各自 + // 打开 meetingConsumer.enabled,否则「共享」名不副实。 + enabled: cfg.meetingConsumer?.enabled ?? true, + consumerProfiles: bound, + // per-bot override 选中角色 → agents;否则跟随全局 defaultMode。 + ...(selected.length > 0 + ? { defaultMode: 'agents' as const, defaultConsumerIds: selected } + : { defaultMode: 'listenOnly' as const }), + }, + }; +} diff --git a/src/setup/open-platform-automation.ts b/src/setup/open-platform-automation.ts index fdafb5e77..64668777c 100644 --- a/src/setup/open-platform-automation.ts +++ b/src/setup/open-platform-automation.ts @@ -1357,6 +1357,50 @@ export async function createOpenPlatformAppWithClient( } } +/** + * Read-only probe: are this app's VC meeting events (vc.bot.meeting_* + + * participant_meeting_joined) subscribed, and is event mode the long connection? + * Uses ONLY the cached Feishu Web session (disableQrLogin) and never publishes a + * version — so it is safe to call at daemon startup. The caller decides whether + * to run the full (publishing) automateOpenPlatformSetup based on the result: + * only when events are actually missing / mode is wrong. + */ +export type VcMeetingEventProbeResult = + | { ok: true; missingVcEvents: string[]; eventModeReady: boolean; sessionFile?: string } + | { ok: false; reason: string; message: string; sessionFile?: string }; + +export async function probeVcMeetingEventSubscription( + appId: string, + options: Pick = {}, +): Promise { + const prepared = await prepareFeishuWebSession({ + ...options, + disableQrLogin: true, + disableBytedcliFallback: true, + }); + if (!prepared.ok) { + return { ok: false, reason: prepared.reason, message: prepared.message, sessionFile: prepared.sessionFile }; + } + const clientResult = await createOpenPlatformApiClient(prepared.cookies, { fetchImpl: options.fetchImpl }); + if (!clientResult.ok) { + return { ok: false, reason: clientResult.reason, message: clientResult.message, sessionFile: prepared.sessionFile }; + } + try { + const eventState = extractOpenPlatformEventState( + await clientResult.client.postJson(`/developers/v1/event/${appId}`, { needEventDetail: true }), + ); + const has = (name: string) => eventState.events.includes(name); + return { + ok: true, + missingVcEvents: VC_MEETING_BOT_EVENTS.filter(name => !has(name)), + eventModeReady: eventState.eventMode === LONG_CONNECTION_EVENT_MODE, + sessionFile: prepared.sessionFile, + }; + } catch (err: any) { + return { ok: false, reason: 'api_error', message: `读取事件订阅失败: ${safeErrorMessage(err)}`, sessionFile: prepared.sessionFile }; + } +} + /** * 单次飞书 Web 扫码完成应用创建。session 会写入 ~/.botmux,后续 * automateOpenPlatformSetup 会直接复用,因此权限/redirect/发版不再二次扫码。 diff --git a/src/types.ts b/src/types.ts index 0557723b4..43e2e1a03 100644 --- a/src/types.ts +++ b/src/types.ts @@ -92,6 +92,16 @@ export interface VcMeetingConsumerConfig { defaultAgentAppId?: string; /** Default profile ids used by defaultMode=agents. */ defaultConsumerIds?: string[]; + /** + * Per-bot default role picked from the fleet-shared consumer catalog. When a + * bot inherits the shared catalog (no own `consumerProfiles`), this single id + * overrides the catalog's global default for THIS bot — "not chosen = follow + * the global default". Unlike `defaultConsumerIds`, this field is independent + * of `consumerProfiles` (the bot doesn't own profiles; it inherits them), so + * it is normalized unconditionally and never triggers the legacy + * "consumerProfiles required" resolver gate. + */ + catalogDefaultConsumerId?: string; /** Presence of this property opts into profile mode, including an explicit empty array. */ consumerProfiles?: VcMeetingConsumerProfileConfig[]; /** Generator provenance; never grants runtime authority. */ @@ -106,6 +116,21 @@ export interface VcMeetingConsumerConfig { minBatchItems?: number; /** Maximum time to hold a non-empty meeting delta before injecting to the agent. Defaults in daemon. */ maxInjectIntervalMs?: number; + /** + * In-meeting TEXT output policy for the selected agent. `allow` sends managed + * text into the meeting without per-message human approval; `approval` (the + * pre-2026-08 behavior) requires the operator to approve each send via card; + * `deny` blocks it. Defaults to `allow` when unset. Voice stays gated on + * realtimeVoice regardless of this field. + */ + textOutputPolicy?: 'deny' | 'approval' | 'allow'; + /** + * In-meeting VOICE output policy. Only takes effect when realtimeVoice is + * enabled (otherwise voice is always denied). `approval` (default when + * enabled) reviews each utterance; `allow` speaks without approval; `deny` + * blocks voice even while realtimeVoice is on. + */ + voiceOutputPolicy?: 'deny' | 'approval' | 'allow'; /** Legacy allowlist. Omitted or [] dynamically shows usable online bots. */ agentCandidates?: VcMeetingConsumerAgentConfig[]; } diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index 8eb4a0e37..9063ab74a 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -139,11 +139,13 @@ describe('API-only bot mode — runtime Feishu transport gates (source lock)', ( // bot-registry.test.ts), which returns undefined for apiOnly. const block = region(daemonSource, 'function effectiveVcMeetingAgentConfig(', 'function configuredVcMeetingListenerChatId('); expect(block).toContain('vcMeetingAgentConfigActive(getBot(larkAppId)?.config)'); - // The predicate itself fail-closes apiOnly BEFORE the enabled check. + // The predicate itself fail-closes apiOnly BEFORE any enabled logic. const pred = region(registrySource, 'export function vcMeetingAgentConfigActive(', 'export function registerBot('); expect(pred).toContain('if (cfg.apiOnly === true) return undefined;'); + // Bot-agnostic join (2026-08): VC is active by default; enabled:false is the + // per-bot opt-out. apiOnly must still short-circuit BEFORE that opt-out check. expect(pred.indexOf('apiOnly === true) return undefined')) - .toBeLessThan(pred.indexOf('vcMeetingAgent?.enabled === true')); + .toBeLessThan(pred.indexOf('vcMeetingAgent?.enabled === false')); }); }); @@ -192,6 +194,96 @@ describe('API-only bot mode — bot-level primitive boundary (source lock)', () expect(block).toContain('larkTransportEnabled({ chatId: ds.chatId, apiOnly: getBot(ds.larkAppId).config.apiOnly })'); }); + it('managedAuxUiSuppressed no longer special-cases a VC meeting agent (Plan B: ordinary chat session)', () => { + // Plan B: a VC meeting agent is an ordinary chat-scope session, so its + // streaming card / reactions flow through the ordinary suppression path just + // like any group session. The old `vcMeetingReceiver` branch (and the + // exposeReceiverStreamingCard opt-in it delegated to) is gone; the final + // group-posting policy (silent vs listener_thread) is enforced separately in + // managedFinalOutputSuppressed, not here. + const block = region(workerPoolSource, 'const managedAuxUiSuppressed =', 'const managedFinalOutputSuppressed'); + expect(block).not.toContain('vcMeetingReceiver'); + expect(block).not.toContain('vcReceiverStreamingCardSuppressed'); + expect(block).toContain('return ordinaryManagedSuppression(turnId, dispatchAttempt);'); + }); + + it('managedFinalOutputSuppressed gates the VC durable policy to meeting-driven turns only (plain user turns post normally)', () => { + // Plan B: the meeting agent hosts BOTH transcript deliveries and plain user + // IM turns. The durable silent/listener_thread policy must apply only to a + // meeting-driven turn — a durable delivery (dispatchAttempt) or a stamped + // meeting @mention follow-up (isMeetingDrivenTurn). A plain user turn has + // neither and must fall to ordinaryManagedSuppression so the user's own reply + // is never wrongly suppressed. + const block = region(workerPoolSource, 'const managedFinalOutputSuppressed', 'const bot = getBot(ds.larkAppId);'); + expect(block).toContain('if (!isMeetingDrivenTurn(ds, turnId, dispatchAttempt)) {'); + expect(block).toContain('return ordinaryManagedSuppression(turnId, dispatchAttempt);'); + // The durable send-policy check still runs for meeting-driven turns. + expect(block).toContain('evaluateVcMeetingManagedSend(config.session.dataDir, {'); + }); + + it('isMeetingDrivenTurn distinguishes transcript deliveries / stamped follow-ups from plain user turns', () => { + // The shared module-level gate both managedAuxUiSuppressed / + // managedFinalOutputSuppressed (setupWorkerHandlers) and deliverFinalOutput + // consult. A non-meeting session is never meeting-driven; a delivery carries a + // dispatchAttempt; an @mention follow-up is recognised by its stamped origin. + const block = region(workerPoolSource, 'function isMeetingDrivenTurn(', 'function setupWorkerHandlers('); + expect(block).toContain('if (!ds.session.vcMeetingReceiver) return false;'); + expect(block).toContain('if (dispatchAttempt !== undefined) return true;'); + expect(block).toContain('return resolveVcMeetingImTurnOrigin(ds.session, turnId) !== undefined;'); + }); + + it('VC delivery dispatch arms the streaming-card turn (beginNewTurn) for every meeting-agent delivery', () => { + // triggerSessionTurn (the VC transcript-delivery route) never calls + // beginNewTurn, so the card lifecycle must be armed here — for every delivery + // turn now that the meeting agent is an ordinary session whose card should + // surface (no exposeReceiverStreamingCard opt-in gate anymore). + const block = region(daemonSource, 'dispatchTurn: (request, context) => {', 'return triggerSessionTurn('); + expect(block).not.toContain('exposeReceiverStreamingCard'); + expect(block).toContain('beginNewTurn(target, title, context.stableTurnId)'); + expect(block).toContain('target?.session.vcMeetingReceiver && context.stableTurnId'); + }); + + it('Plan B keeps in-meeting output: action-request still recognises the meeting agent via the retained marker', () => { + // The whole in-meeting text/voice output chain (request-output → action-request + // → managed-action) authorizes against the RETAINED vcMeetingReceiver marker + + // managedTurnOrigin/vcMeetingImTurnOrigin, all keyed by sessionId — never by the + // activeSessions map key that Stage 1 changed. This is why in-meeting output + // survives the normal-session refactor with no code change. Pin the entry guard + // so a future marker cleanup can't silently kill 会中发言. + const block = region(daemonSource, "ipcRoute('POST', '/api/vc-meetings/action-request'", 'const claimedAttempt ='); + expect(block).toContain('findActiveBySessionId(receiverSessionId)'); + expect(block).toContain('if (!ds?.session.vcMeetingReceiver) {'); + expect(block).toContain("errorCode: 'not_receiver_session'"); + }); + + it('Plan B idle-gap: request-output falls back to the durable receipt when the live managedTurnOrigin was cleared', () => { + // A meeting agent reaches idle between the delivery turn (which armed + // ds.managedTurnOrigin) and the moment it runs request-output — the origin is + // cleared at the delivery turn's terminal edge, so the live-origin gate fails. + // The handler must fall back to re-deriving authority from the DURABLE delivery + // receipt (evaluateVcMeetingManagedSend) for a claimed delivery origin, which + // never authorizes anything the receipt itself wouldn't (attempt match + + // dispatched/completed status + active projection). It uses forInMeetingOutput + // so a silent responseMode (which only gates listener auto-post) does NOT block + // in-meeting speech — the hub still applies capability + text/voiceOutputPolicy. + const block = region(daemonSource, 'let effectiveVerified = verified;', 'if (!effectiveVerified.ok) return jsonRes'); + // The fallback runs whenever live verification failed — including while a + // delivery turn is still executing. Live verification proves origin via the + // rotating worker capability only, and non-sandboxed sessions have no + // origin-channel transport for it, so gating the fallback on "live origin + // cleared" (the old `!ds.managedTurnOrigin` guard) hard-bricked in-turn + // speech from non-sandboxed meeting agents (idle-gap gate #5). + expect(block).not.toContain('!ds.managedTurnOrigin'); + expect(block).toContain('claimedAttempt !== undefined'); + expect(block).toContain('evaluateVcMeetingManagedSend(config.session.dataDir, {'); + expect(block).toContain('allowTerminalReceipt: true'); + // In-meeting output channel is silent-independent (decoupled from responseMode). + expect(block).toContain('forInMeetingOutput: true'); + // Only a listener_thread durable decision synthesizes the verified origin. + expect(block).toContain("durable.ok && durable.kind === 'listener_thread'"); + expect(block).toContain('dispatchAttempt: claimedAttempt'); + }); + it('scheduleCardPatch is a defense-in-depth no-op for no-transport sessions', () => { const block = region(workerPoolSource, 'export function scheduleCardPatch(', 'if (streamingCardDisabled(ds, turnId)) return;'); expect(block).toContain('larkTransportEnabled({ chatId: ds.chatId, apiOnly: getBot(ds.larkAppId).config.apiOnly })'); @@ -394,15 +486,42 @@ describe('API-only bot mode — riff env re-freeze + VC listener exclusion (sour expect(block.indexOf('...cfg.backendConfig.env')).toBeLessThan(block.indexOf('delete mergedEnv.BOTMUX_LARK_APP_SECRET;')); }); - it('excludes apiOnly bots from VC listener options and fail-closes scope fetch', () => { + it('excludes apiOnly bots from VC meeting preflight and fail-closes scope fetch', () => { + // 全局「会议事件接收 Bot」下拉退役后(daemon 侧改成谁收到会议事件谁处理), + // 会给某个 bot 开权限/订事件的入口只剩这一个 preflight。apiOnly bot 结构上收不到 + // 飞书事件,必须在跑开放平台自动化之前就被挡住。 const dashSource = readFileSync(resolve('src/dashboard.ts'), 'utf8'); - const optsBlock = region(dashSource, 'function vcMeetingListenerBotOptions(', '.map(bot => ({'); - expect(optsBlock).toContain('bot.apiOnly !== true'); + const preflightBlock = region(dashSource, 'async function preflightVcMeetingBot(', '\n}\n'); + const guardAt = preflightBlock.indexOf("if (bot.apiOnly === true) return { ok: false, error: 'vcMeetingBot_preflight_api_only' };"); + expect(guardAt).toBeGreaterThan(-1); + // 拦截必须排在任何开放平台调用之前(自动化 + scope 回读)。 + for (const call of ['await automateOpenPlatformSetup(', 'await validateVcMeetingScopesForBot(']) { + const callAt = preflightBlock.indexOf(call); + expect(callAt, `${call} not found`).toBeGreaterThan(-1); + expect(guardAt, `apiOnly guard must precede ${call}`).toBeLessThan(callAt); + } const fetchBlock = region(dashSource, 'async function fetchGrantedScopesForBot(', 'const brand ='); expect(fetchBlock).toContain('bot.apiOnly === true'); expect(fetchBlock).toContain('api_only_bot_has_no_feishu_credentials'); }); + it('never seeds per-bot meeting roles while granting permissions (the cross-bot invite regression)', () => { + // 「拉 A 进会却把 B 拉进监听群」的根因是给 bot 配置时顺手播种了一条 per-bot + // 预设,并把执行方 appId 焊了进去(本 bot 结构上不合格时还会静默换成别人)。 + // 角色预设现在归 fleet 共享目录,执行方在读路径绑定为收到会议事件的 bot 自己; + // 这个入口只负责权限与事件订阅,落盘只允许补 larkCliProfile。 + const dashSource = readFileSync(resolve('src/dashboard.ts'), 'utf8'); + const preflightBlock = region(dashSource, 'async function preflightVcMeetingBot(', '\n}\n'); + for (const forbidden of ['consumerProfiles', 'defaultConsumerIds', 'defaultProfileBootstrap', 'agentAppId']) { + expect(preflightBlock, `preflight must not touch ${forbidden}`).not.toContain(`${forbidden} =`); + expect(preflightBlock, `preflight must not touch ${forbidden}`).not.toContain(`${forbidden}:`); + } + // 唯一允许的落盘字段。 + const writeBlock = region(preflightBlock, 'await withFileLock(', '\n });'); + expect(writeBlock).toContain('next.larkCliProfile = targetAppId;'); + expect(writeBlock).not.toMatch(/next\.(?!larkCliProfile\b)[A-Za-z]+\s*=/u); + }); + it('skips open-platform rename/avatar handler registration for apiOnly (fails closed to local rename)', () => { // Daemon owns the config: with the handler unregistered, the IPC route // returns renamer_not_wired (local displayName only, no console/Feishu call). diff --git a/test/bot-registry.test.ts b/test/bot-registry.test.ts index 6745623a1..62d936f77 100644 --- a/test/bot-registry.test.ts +++ b/test/bot-registry.test.ts @@ -493,8 +493,48 @@ describe('parseBotConfigsFromText — brand', () => { }); }); - it('keeps meetingConsumer disabled/listenOnly configuration explicit', () => { + it('parses meetingConsumer in/out policies', () => { const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([ + { + larkAppId: 'a', + larkAppSecret: 's', + vcMeetingAgent: { + enabled: true, + realtimeVoice: { enabled: true }, + meetingConsumer: { + enabled: true, + defaultMode: 'agents', + textOutputPolicy: 'approval', + voiceOutputPolicy: 'allow', + }, + }, + }, + ])); + expect(cfg.vcMeetingAgent?.meetingConsumer?.textOutputPolicy).toBe('approval'); + expect(cfg.vcMeetingAgent?.meetingConsumer?.voiceOutputPolicy).toBe('allow'); + }); + + it('drops invalid in/out policy values', () => { + const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([ + { + larkAppId: 'a', + larkAppSecret: 's', + vcMeetingAgent: { + enabled: true, + meetingConsumer: { + enabled: true, + defaultMode: 'agents', + textOutputPolicy: 'sometimes', // invalid → dropped + voiceOutputPolicy: 42, // invalid → dropped + }, + }, + }, + ])); + expect(cfg.vcMeetingAgent?.meetingConsumer?.textOutputPolicy).toBeUndefined(); + expect(cfg.vcMeetingAgent?.meetingConsumer?.voiceOutputPolicy).toBeUndefined(); + }); + + it('keeps meetingConsumer disabled/listenOnly configuration explicit', () => { const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([ { larkAppId: 'a', larkAppSecret: 's', @@ -1745,10 +1785,15 @@ describe('vcMeetingAgentConfigActive — apiOnly bots never attend VC meetings', .toBeUndefined(); }); - it('returns undefined when VC is not enabled (normal bot)', () => { + it('is active by default for a Feishu bot; only enabled:false opts out', () => { + // Bot-agnostic join: any invited Feishu bot should join, so VC is active + // unless explicitly disabled. enabled:false is the per-bot opt-out. expect(mod.vcMeetingAgentConfigActive({ vcMeetingAgent: { enabled: false } as any })) .toBeUndefined(); - expect(mod.vcMeetingAgentConfigActive({})).toBeUndefined(); + // Unset enabled / no vcMeetingAgent block → active with an effective config + // (empty object is fine; downstream reads fall back to their own defaults). + expect(mod.vcMeetingAgentConfigActive({ vcMeetingAgent: {} as any })).toEqual({}); + expect(mod.vcMeetingAgentConfigActive({})).toEqual({}); expect(mod.vcMeetingAgentConfigActive(undefined)).toBeUndefined(); }); @@ -1756,6 +1801,25 @@ describe('vcMeetingAgentConfigActive — apiOnly bots never attend VC meetings', expect(mod.vcMeetingAgentConfigActive({ vcMeetingAgent: enabledVc, apiOnly: true })) .toBeUndefined(); }); + + // 回归(PR#916 codex阻断①):VC/实时语音默认开翻转后,enabled:false 是显式退出, + // 必须能 round-trip 存活。这里**过真实 parseBotConfigsFromText → vcMeetingAgentConfigActive** + // (而不是手搓对象喂 active),否则 normalizer 丢 false 的 bug 会被假绿掩盖。 + it('a persisted vcMeetingAgent.enabled:false round-trips through parse and stays opted out', () => { + const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'cli_off', larkAppSecret: 's', vcMeetingAgent: { enabled: false } }, + ])); + expect(cfg.vcMeetingAgent?.enabled).toBe(false); + expect(mod.vcMeetingAgentConfigActive(cfg)).toBeUndefined(); + }); + + it('a persisted realtimeVoice.enabled:false round-trips through parse (voice stays off)', () => { + const [cfg] = mod.parseBotConfigsFromText(JSON.stringify([ + { larkAppId: 'cli_v', larkAppSecret: 's', vcMeetingAgent: { realtimeVoice: { enabled: false } } }, + ])); + // 顶层不 enabled:false → bot 仍接收会议事件,但实时语音显式关闭必须保留。 + expect(cfg.vcMeetingAgent?.realtimeVoice?.enabled).toBe(false); + }); }); // ─── bots.json unreadable (sandbox read isolation) ──────────────────────── diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index b723faf94..1153da68e 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -327,6 +327,13 @@ vi.mock('../src/core/worker-pool.js', () => ({ // /term payload. Default to the in-chat visible-to-you channel; tests override // per-scenario (dm / failed / not_ready). deliverWritableTerminalCardTo: vi.fn(async () => 'ephemeral'), + // /card show path. postFreshStreamingCard returns false for sessions that + // structurally can't post a live card (VC meeting-receiver among them); the + // handler then picks an accurate reason. Default false so /card show tests + // exercise the not-ready / vc-receiver branch; override per-scenario. + postFreshStreamingCard: vi.fn(async () => false), + postPrivateSnapshotCard: vi.fn(async () => ({ notReady: false, sent: 1, total: 1 })), + resolvePrivateCardAudience: vi.fn(() => ['ou_owner']), })); vi.mock('../src/utils/daemon-discovery.js', () => ({ @@ -495,7 +502,7 @@ import { sessionKey } from '../src/core/types.js'; import { setTerminalProxyPort } from '../src/core/terminal-url.js'; import type { DaemonSession } from '../src/core/types.js'; import type { LarkMessage, Session } from '../src/types.js'; -import { closeSession, closeSession as closeWorkerPoolSession, killWorker, teardownAuthoritativePersistentBackingBeforeClose, suspendWorker, forkWorker, forkAdoptWorker, forkSession, isForkCapableSession, getCurrentCliVersion, deliverEphemeralOrReply, deliverWritableTerminalCardTo, requestSessionRestart, withActiveSessionKeyLock } from '../src/core/worker-pool.js'; +import { closeSession, closeSession as closeWorkerPoolSession, killWorker, teardownAuthoritativePersistentBackingBeforeClose, suspendWorker, forkWorker, forkAdoptWorker, forkSession, isForkCapableSession, getCurrentCliVersion, deliverEphemeralOrReply, deliverWritableTerminalCardTo, requestSessionRestart, withActiveSessionKeyLock, postFreshStreamingCard } from '../src/core/worker-pool.js'; import { dashboardEventBus, type DashboardEvent } from '../src/core/dashboard-events.js'; import { publishClosedSessionPatch } from '../src/core/session-activity.js'; import { getOwnerOpenId } from '../src/bot-registry.js'; @@ -5947,6 +5954,25 @@ describe('/card — operator / canOperate gate', () => { const reply = (deps.sessionReply as ReturnType).mock.calls[0][1] as string; expect(reply).toContain('已恢复'); }); + + it('Plan B: /card on a VC meeting-agent session gets the ordinary not-ready notice (no special-casing)', async () => { + // Under Plan B a meeting agent is an ordinary chat-scope session, so /card + // behaves exactly like any other session — postFreshStreamingCard no longer + // structurally refuses it, and there is no meeting-receiver-specific reason. + // When a post genuinely can't happen yet, the operator sees the same generic + // not-ready text as every other session. + vi.mocked(getBot).mockImplementation(((id: string = 'app-1') => ({ + botName: 'Claude', + config: { larkAppId: id, larkAppSecret: 's', cliId: 'claude-code' as const, privateCard: false }, + })) as any); + vi.mocked(postFreshStreamingCard).mockResolvedValue(false); + const ds = makeDaemonSession({ session: makeSession({ vcMeetingReceiver: true }) }); + const deps = makeDeps(ds); + await handleCardCommand(ROOT_ID, LARK_APP_ID, CHAT_ID, 'ou_owner', '/card', deps); + const reply = (deps.sessionReply as ReturnType).mock.calls[0][1] as string; + expect(reply).toContain('终端尚未就绪'); + expect(reply).not.toContain('会议接收会话'); + }); }); describe('/term — operable terminal slash command (operator / canOperate)', () => { diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index 1611bf957..503edd1c4 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -2242,7 +2242,7 @@ describe('PUT /api/bot-read-isolation', () => { }); describe('POST /api/sessions/:sessionId/resume', () => { - it('rejects a managed VC receiver without reactivating or waking it', async () => { + it('Plan B: resumes a closed meeting-agent session as an ordinary chat session (wake=1)', async () => { const dataDir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-resume-')); const prevConfigDataDir = config.session.dataDir; const registry = new Map(); @@ -2257,6 +2257,9 @@ describe('POST /api/sessions/:sessionId/resume', () => { session.scope = 'chat'; session.cliId = 'codex' as any; session.workingDir = process.cwd(); + // The vcMeetingReceiver marker is now pure delivery metadata; it no longer + // blocks resume. A closed meeting-agent session reactivates into its + // ordinary (chatId, appId) chat slot like any chat session. session.vcMeetingReceiver = { listenerAppId: 'listener-app', meetingId: 'meeting-42', @@ -2272,14 +2275,12 @@ describe('POST /api/sessions/:sessionId/resume', () => { { method: 'POST' }, ); - expect(res.status).toBe(409); - expect(await res.json()).toEqual({ - ok: false, - error: 'vc_receiver_managed', - }); - expect(sessionStore.getSession(session.sessionId)?.status).toBe('closed'); - expect(registry.size).toBe(0); - expect(forkSpy).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ ok: true, sessionId: session.sessionId, wake: true }); + // Reactivated at the ordinary chat slot and forked. + expect(registry.get(sessionKey('oc_listener', ''))?.session.sessionId).toBe(session.sessionId); + expect(sessionStore.getSession(session.sessionId)?.status).toBe('active'); + expect(forkSpy).toHaveBeenCalled(); } finally { forkSpy.mockRestore(); workerPool.setActiveSessionsRegistry(new Map()); diff --git a/test/dashboard-token-usage-row.test.ts b/test/dashboard-token-usage-row.test.ts index 574bb7479..f24b3b0e6 100644 --- a/test/dashboard-token-usage-row.test.ts +++ b/test/dashboard-token-usage-row.test.ts @@ -44,6 +44,34 @@ function makeDs(): DaemonSession { } as DaemonSession; } +describe('dashboard SessionRow status projection', () => { + it('projects working (not starting) during a long first turn once the worker initialized', () => { + // Regression: meeting-agent sessions are fed a transcript delivery right at + // spawn, so the CLI runs a minutes-long first turn before its first idle + // prompt — screen updates are suppressed until then (awaitingFirstPrompt), + // leaving lastScreenStatus unset. These sessions used to sit in「启动中」the + // whole time even though the CLI was actively working. + const ds = makeDs(); + (ds as { worker: unknown }).worker = { killed: false }; + (ds as { workerReady?: boolean }).workerReady = true; + expect(composeRowFromActive(ds).status).toBe('working'); + }); + + it('keeps starting while the worker has not finished init', () => { + const ds = makeDs(); + (ds as { worker: unknown }).worker = { killed: false }; + expect(composeRowFromActive(ds).status).toBe('starting'); + }); + + it('screen status still wins once reported', () => { + const ds = makeDs(); + (ds as { worker: unknown }).worker = { killed: false }; + (ds as { workerReady?: boolean }).workerReady = true; + (ds as { lastScreenStatus?: string }).lastScreenStatus = 'idle'; + expect(composeRowFromActive(ds).status).toBe('idle'); + }); +}); + describe('dashboard SessionRow token usage', () => { it('carries native token in/out totals for the sessions table', () => { const row = composeRowFromActive(makeDs()); diff --git a/test/dashboard-vc-consumer-profiles-api.test.ts b/test/dashboard-vc-consumer-profiles-api.test.ts index 9195e421b..25fc64993 100644 --- a/test/dashboard-vc-consumer-profiles-api.test.ts +++ b/test/dashboard-vc-consumer-profiles-api.test.ts @@ -8,27 +8,24 @@ import { vcMeetingConsumerProfilesFromDtos, } from '../src/dashboard/vc-consumer-profiles-api.js'; import type { - VcMeetingAgentOptionDto, VcMeetingConsumerProfileDto, VcMeetingConsumerProfilesApiDeps, VcMeetingPermissionPreset, } from '../src/dashboard/vc-consumer-profiles-api.js'; -import { - seedVcMeetingDefaultConsumerProfile, - selectVcMeetingDefaultConsumerAgent, -} from '../src/services/vc-meeting-consumer-profile-bootstrap.js'; -import type { VcMeetingConsumerProfileConfig } from '../src/types.js'; -import type { VcMeetingConsumerProfilesSnapshot } from '../src/services/vc-meeting-consumer-profile-store.js'; +import type { VcMeetingSharedConsumerProfile } from '../src/global-config.js'; +import type { + VcMeetingSharedConsumerCatalogSnapshot, +} from '../src/services/vc-meeting-shared-consumer-catalog-store.js'; import type { BotConfig } from '../src/bot-registry.js'; const READ = 'meeting.read'; const OUTPUT = 'meeting.output.request'; const LISTENER = 'listener.output.request'; -function canonical(over: Partial = {}): VcMeetingConsumerProfileConfig { +/** 共享目录条目刻意**不带** `agentAppId`:执行方在读路径绑定为收到事件的 bot。 */ +function canonical(over: Partial = {}): VcMeetingSharedConsumerProfile { return { id: 'minutes', - agentAppId: 'app_agent', role: 'minutes', responseMode: 'silent', capabilities: [READ], @@ -39,7 +36,6 @@ function canonical(over: Partial = {}): VcMeetin function dto(over: Partial = {}): VcMeetingConsumerProfileDto { return { id: 'minutes', - agentAppId: 'app_agent', responseMode: 'silent', permissionPreset: 'observe_only', ...over, @@ -47,7 +43,7 @@ function dto(over: Partial = {}): VcMeetingConsumer } describe('deriveVcMeetingPermissionPreset', () => { - const cases: Array<[VcMeetingPermissionPreset, VcMeetingConsumerProfileConfig]> = [ + const cases: Array<[VcMeetingPermissionPreset, VcMeetingSharedConsumerProfile]> = [ ['observe_only', canonical()], ['observe_only', canonical({ responseMode: 'listener_thread', capabilities: [LISTENER, READ] })], ['meeting_text', canonical({ capabilities: [OUTPUT, READ], ownedSinks: ['meeting_text'] })], @@ -239,7 +235,6 @@ describe('vcMeetingConsumerProfilesFromDtos ↔ vcMeetingConsumerProfileToDto', it('reports field-level errors with DTO paths', () => { const mapped = vcMeetingConsumerProfilesFromDtos([ dto({ id: ' ' }), - dto({ id: 'b', agentAppId: '' }), dto({ id: 'c', responseMode: 'broadcast' as never }), dto({ id: 'd', permissionPreset: 'root' as never }), dto({ id: 'e', activityTypes: ['transcript_received', 'nope'] }), @@ -250,15 +245,24 @@ describe('vcMeetingConsumerProfilesFromDtos ↔ vcMeetingConsumerProfileToDto', if (mapped.ok) return; expect(mapped.fieldErrors.map(e => e.path)).toEqual([ 'profiles[0].id', - 'profiles[1].agentAppId', - 'profiles[2].responseMode', - 'profiles[3].permissionPreset', - 'profiles[4].activityTypes', - 'profiles[5].instructions', - 'profiles[6].listenerPlacement', + 'profiles[1].responseMode', + 'profiles[2].permissionPreset', + 'profiles[3].activityTypes', + 'profiles[4].instructions', + 'profiles[5].listenerPlacement', ]); }); + // 共享目录里没有执行方这一维:DTO 夹带 agentAppId 也不会被写进去。 + it('ignores an injected agentAppId — the shared catalog has no executor field', () => { + const mapped = vcMeetingConsumerProfilesFromDtos( + [{ ...dto(), agentAppId: 'app_other_bot' } as VcMeetingConsumerProfileDto], [], + ); + expect(mapped.ok).toBe(true); + if (!mapped.ok) return; + expect(mapped.profiles[0]).not.toHaveProperty('agentAppId'); + }); + it('trims label/instructions, drops empties, sorts+dedups activityTypes', () => { const mapped = vcMeetingConsumerProfilesFromDtos([dto({ label: ' 会议纪要 ', @@ -276,9 +280,10 @@ describe('vcMeetingConsumerProfilesFromDtos ↔ vcMeetingConsumerProfileToDto', }); }); -function snapshot(over: Partial = {}): VcMeetingConsumerProfilesSnapshot { +function snapshot( + over: Partial = {}, +): VcMeetingSharedConsumerCatalogSnapshot { return { - listenerBotAppId: 'app_listener', revision: 'sha256:rev1', catalogState: 'profiles', defaultMode: 'listenOnly', @@ -293,8 +298,8 @@ function makeDeps(over: Partial = {}): VcMeeti larkAppId: 'app_agent', name: 'agent-a', displayName: 'Agent A', cliId: 'claude', } as unknown as BotConfig; return { - readSnapshot: vi.fn(async () => snapshot()), - updateSnapshot: vi.fn(async (_id, input) => ({ + readCatalog: vi.fn(async () => snapshot()), + updateCatalog: vi.fn(async input => ({ ok: true as const, snapshot: snapshot({ revision: 'sha256:rev2', @@ -311,13 +316,26 @@ function makeDeps(over: Partial = {}): VcMeeti managedSideEffectEligible: vi.fn(() => true), sandboxIsolated: vi.fn(() => true), reloadDaemons: vi.fn(async () => {}), + applyBotOutputPolicy: vi.fn(async () => ({ ok: true })), ...over, }; } +const DEFAULT_POLICY_FIELDS = { + // 缺省 = 接收会议事件:VC 对每个连着飞书的 bot 默认可用,enabled:false 才是退出。 + vcEnabled: true, + vcEligible: true, + textOutputPolicy: null, + voiceOutputPolicy: null, + // 实时语音能力默认开启(未显式配 = 开);语音生效值随之默认 allow。 + realtimeVoiceEnabled: true, + catalogDefaultConsumerId: null, + effectiveTextOutputPolicy: 'allow', + effectiveVoiceOutputPolicy: 'allow', +} as const; + function putRequest(over: Record = {}): Record { return { - listenerBotAppId: 'app_listener', expectedRevision: 'sha256:rev1', defaultMode: 'listenOnly', defaultConsumerIds: [], @@ -327,7 +345,7 @@ function putRequest(over: Record = {}): Record } describe('buildVcMeetingAgentOptions', () => { - it('maps registry bots to the isolation-aware option DTO (listener not excluded)', () => { + it('maps registry bots to the isolation-aware option DTO', () => { const deps = makeDeps(); expect(buildVcMeetingAgentOptions(deps)).toEqual([{ appId: 'app_agent', @@ -338,6 +356,7 @@ describe('buildVcMeetingAgentOptions', () => { reliableTurnTerminal: true, managedSideEffectEligible: true, sandboxIsolated: true, + ...DEFAULT_POLICY_FIELDS, }]); }); @@ -358,9 +377,26 @@ describe('buildVcMeetingAgentOptions', () => { reliableTurnTerminal: false, managedSideEffectEligible: true, sandboxIsolated: true, + ...DEFAULT_POLICY_FIELDS, }]); }); + it('shows an OFFLINE bot\'s persisted Feishu name (not the raw appId)', () => { + // Regression: cli_xxx bots that are offline used to fall through to appId in + // the dropdown. The dashboard now feeds onlineBotName from bots-info.json so + // an offline bot keeps its friendly name. Here isOnline=false but the name + // resolver still returns the persisted name. + const bot = { larkAppId: 'cli_offline', name: '' } as unknown as BotConfig; + const deps = makeDeps({ + loadBotConfigs: vi.fn(() => [bot]), + onlineBotName: vi.fn(() => 'LastResort(Codex)'), + isOnline: vi.fn(() => false), + }); + const options = buildVcMeetingAgentOptions(deps); + expect(options[0]?.label).toBe('LastResort(Codex)'); + expect(options[0]?.online).toBe(false); + }); + it('returns [] when config loading throws (options degrade, not 500)', () => { const deps = makeDeps({ loadBotConfigs: vi.fn(() => { throw new Error('boom'); }) }); expect(buildVcMeetingAgentOptions(deps)).toEqual([]); @@ -375,127 +411,35 @@ describe('buildVcMeetingAgentOptions', () => { expect(buildVcMeetingAgentOptions(deps).map(option => option.appId)) .toEqual(['app_a', 'app_m', 'app_z']); }); -}); - -function agentOption( - appId: string, - over: Partial = {}, -): VcMeetingAgentOptionDto { - return { - appId, - label: appId, - online: true, - workingDirReady: true, - reliableTurnTerminal: true, - managedSideEffectEligible: true, - sandboxIsolated: true, - ...over, - }; -} - -describe('default VC consumer profile bootstrap', () => { - it('selects an eligible explicit preference, then listener self, then lexical external fallback', () => { - const options = [agentOption('app_z'), agentOption('app_listener'), agentOption('app_a')]; - expect(selectVcMeetingDefaultConsumerAgent('app_listener', options, ['app_z'])?.appId).toBe('app_z'); - expect(selectVcMeetingDefaultConsumerAgent('app_listener', options)?.appId).toBe('app_listener'); - expect(selectVcMeetingDefaultConsumerAgent('missing_listener', options)?.appId).toBe('app_a'); - }); - - it('requires structural readiness but ignores transient online state', () => { - const options = [ - agentOption('app_no_dir', { workingDirReady: false }), - agentOption('app_no_terminal', { reliableTurnTerminal: false }), - agentOption('app_ineligible', { managedSideEffectEligible: false }), - agentOption('app_offline_ready', { online: false }), - ]; - expect(selectVcMeetingDefaultConsumerAgent('app_no_dir', options)?.appId) - .toBe('app_offline_ready'); - expect(selectVcMeetingDefaultConsumerAgent('x', options.slice(0, 3))).toBeUndefined(); - }); - - it('seeds a visible full-capability minutes profile on the first enable', () => { - const meetingConsumer: Record = { - enabled: true, - injectIntervalMs: 30_000, - }; - expect(seedVcMeetingDefaultConsumerProfile( - meetingConsumer, - 'app_listener', - [agentOption('app_z'), agentOption('app_listener')], - )).toBe(true); - expect(meetingConsumer).toMatchObject({ - enabled: true, - injectIntervalMs: 30_000, - defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - consumerProfiles: [{ - id: 'minutes', - agentAppId: 'app_listener', - label: '会议纪要', - role: 'minutes', - responseMode: 'listener_thread', - capabilities: ['listener.output.request', 'meeting.output.request', 'meeting.read'], - ownedSinks: ['meeting_text', 'meeting_voice'], - }], - }); - expect((meetingConsumer.consumerProfiles as Array>)[0]?.instructions) - .toContain('无实质增量时保持静默'); - expect(meetingConsumer.defaultProfileBootstrap).toMatchObject({ - generatorVersion: 2, - profileId: 'minutes', - }); - }); - it('does not seed without a structurally eligible agent', () => { - const meetingConsumer: Record = { enabled: true }; - expect(seedVcMeetingDefaultConsumerProfile( - meetingConsumer, - 'app_listener', - [agentOption('app_listener', { reliableTurnTerminal: false })], - )).toBe(false); - expect(meetingConsumer).toEqual({ enabled: true }); - }); - - it.each([ - ['an explicit empty catalog', { consumerProfiles: [] }], - ['an existing catalog', { consumerProfiles: [{ id: 'existing' }] }], - ['legacy defaultAgentAppId', { defaultAgentAppId: 'app_old' }], - ['legacy defaultAgent alias', { defaultAgent: 'app_old' }], - ['legacy candidate list', { agentCandidates: [] }], - ['legacy agents alias', { agents: [] }], - ['legacy agent mode', { defaultMode: 'agent' }], - ['an explicit listen-only mode', { defaultMode: 'listenOnly' }], - ['an incomplete profile default', { defaultMode: 'agents' }], - ['explicit profile ids', { defaultConsumerIds: [] }], - ])('preserves %s instead of implicitly migrating it', (_name, existing) => { - const meetingConsumer: Record = { enabled: true, ...existing }; - const before = structuredClone(meetingConsumer); - expect(seedVcMeetingDefaultConsumerProfile( - meetingConsumer, - 'app_listener', - [agentOption('app_listener')], - )).toBe(false); - expect(meetingConsumer).toEqual(before); + // 每个 bot 一行开关取代了旧的「会议事件接收 Bot」单选:缺省接收,显式 + // enabled:false 才退出,apiOnly(无飞书连接)结构上不可能收会议事件。 + it('exposes the per-bot VC receive switch: default on, explicit off, apiOnly ineligible', () => { + const bots = [ + { larkAppId: 'app_default', cliId: 'claude' }, + { larkAppId: 'app_off', cliId: 'claude', vcMeetingAgent: { enabled: false } }, + { larkAppId: 'app_api_only', cliId: 'claude', apiOnly: true }, + { larkAppId: 'app_on', cliId: 'claude', vcMeetingAgent: { enabled: true } }, + ] as unknown as BotConfig[]; + const deps = makeDeps({ loadBotConfigs: vi.fn(() => bots) }); + expect(buildVcMeetingAgentOptions(deps).map(o => [o.appId, o.vcEnabled, o.vcEligible])).toEqual([ + ['app_api_only', false, false], + ['app_default', true, true], + ['app_off', false, true], + ['app_on', true, true], + ]); }); }); describe('handleVcMeetingConsumerProfilesGet', () => { - it('400 on empty listenerBotAppId', async () => { - const out = await handleVcMeetingConsumerProfilesGet(' ', makeDeps()); - expect(out.status).toBe(400); - }); - - it('404 when bot missing, 503 when config unreadable', async () => { - expect((await handleVcMeetingConsumerProfilesGet( - 'x', makeDeps({ readSnapshot: vi.fn(async () => undefined) }), - )).status).toBe(404); + it('503 when the shared catalog is unreadable', async () => { expect((await handleVcMeetingConsumerProfilesGet( - 'x', makeDeps({ readSnapshot: vi.fn(async () => { throw new Error('io'); }) }), + makeDeps({ readCatalog: vi.fn(async () => { throw new Error('io'); }) }), )).status).toBe(503); }); it('200 returns DTO profiles + agentOptions + revision', async () => { - const out = await handleVcMeetingConsumerProfilesGet('app_listener', makeDeps()); + const out = await handleVcMeetingConsumerProfilesGet(makeDeps()); expect(out.status).toBe(200); if (out.status !== 200) return; expect(out.body.revision).toBe('sha256:rev1'); @@ -507,36 +451,37 @@ describe('handleVcMeetingConsumerProfilesGet', () => { templates: [ { templateId: 'important-information-sync', version: 1, source: 'builtin' }, { templateId: 'meeting-minutes', version: 2, source: 'builtin' }, - { templateId: 'meeting-facilitator', version: 1, source: 'builtin' }, + { templateId: 'meeting-facilitator', version: 2, source: 'builtin' }, { templateId: 'solution-review-risk-challenge', version: 1, source: 'builtin' }, { templateId: 'interview-requirement-insights', version: 1, source: 'builtin' }, ], }); }); - it('GET exposes an explicit legacy-seed migration offer without mutating config', async () => { + // 「从没配置过」也要有可跑的角色:Dashboard 直接展示内置默认目录,读路径不写盘。 + it('GET on a never-configured catalog exposes the built-in default without writing', async () => { const deps = makeDeps({ - readSnapshot: vi.fn(async () => snapshot({ - migrationOffer: 'enable_seeded_minutes_default', + readCatalog: vi.fn(async () => snapshot({ + catalogState: 'uninitialized', + defaultMode: 'agents', + defaultConsumerIds: ['minutes'], })), }); - const out = await handleVcMeetingConsumerProfilesGet('app_listener', deps); + const out = await handleVcMeetingConsumerProfilesGet(deps); expect(out.status).toBe(200); if (out.status !== 200) return; - expect(out.body.migrationOffer).toBe('enable_seeded_minutes_default'); - expect(deps.updateSnapshot).not.toHaveBeenCalled(); + expect(out.body.catalogState).toBe('uninitialized'); + expect(out.body.defaultConsumerIds).toEqual(['minutes']); + expect(deps.updateCatalog).not.toHaveBeenCalled(); }); }); describe('handleVcMeetingConsumerProfilesPut', () => { - it('400 on non-object payload / missing appId / missing revision', async () => { + it('400 on non-object payload / missing revision', async () => { const deps = makeDeps(); for (const payload of [null, 'x', [1]]) { expect((await handleVcMeetingConsumerProfilesPut(payload, deps)).status).toBe(400); } - expect((await handleVcMeetingConsumerProfilesPut( - putRequest({ listenerBotAppId: ' ' }), deps, - )).body).toMatchObject({ error: 'listenerBotAppId_required' }); expect((await handleVcMeetingConsumerProfilesPut( putRequest({ expectedRevision: undefined }), deps, )).body).toMatchObject({ error: 'expectedRevision_required' }); @@ -555,7 +500,7 @@ describe('handleVcMeetingConsumerProfilesPut', () => { expect(out.status).toBe(422); expect(out.status === 422 && out.body.fieldErrors?.[0]?.path).toBe(path); } - expect(deps.updateSnapshot).not.toHaveBeenCalled(); + expect(deps.updateCatalog).not.toHaveBeenCalled(); }); it('422 on DTO mapping failure without touching the store', async () => { @@ -565,17 +510,17 @@ describe('handleVcMeetingConsumerProfilesPut', () => { ); expect(out.status).toBe(422); expect(out.status === 422 && out.body.fieldErrors?.[0]?.path).toBe('profiles[0].permissionPreset'); - expect(deps.updateSnapshot).not.toHaveBeenCalled(); + expect(deps.updateCatalog).not.toHaveBeenCalled(); }); it('custom reuse maps from the CURRENT stored policy of the same id', async () => { const stored = canonical({ capabilities: [LISTENER, OUTPUT, READ], ownedSinks: ['meeting_text'] }); - const deps = makeDeps({ readSnapshot: vi.fn(async () => snapshot({ profiles: [stored] })) }); + const deps = makeDeps({ readCatalog: vi.fn(async () => snapshot({ profiles: [stored] })) }); const out = await handleVcMeetingConsumerProfilesPut( putRequest({ profiles: [dto({ permissionPreset: 'custom' })] }), deps, ); expect(out.status).toBe(200); - const sent = vi.mocked(deps.updateSnapshot).mock.calls[0][1]; + const sent = vi.mocked(deps.updateCatalog).mock.calls[0][0]; expect(sent.profiles[0].capabilities).toEqual([LISTENER, OUTPUT, READ]); expect(sent.profiles[0].ownedSinks).toEqual(['meeting_text']); }); @@ -585,23 +530,22 @@ describe('handleVcMeetingConsumerProfilesPut', () => { await handleVcMeetingConsumerProfilesPut( putRequest({ defaultMode: 'agents', defaultConsumerIds: ['ghost', 'minutes'] }), deps, ); - expect(vi.mocked(deps.updateSnapshot).mock.calls[0][1].defaultConsumerIds) + expect(vi.mocked(deps.updateCatalog).mock.calls[0][0].defaultConsumerIds) .toEqual(['ghost', 'minutes']); }); - it('maps store outcomes: 409 conflict / 422 fieldErrors passthrough / 404 / 503', async () => { - const failures: Array<[Parameters[0]['updateSnapshot'], number]> = [ + it('maps store outcomes: 409 conflict / 422 fieldErrors passthrough / 503', async () => { + const failures: Array<[Parameters[0]['updateCatalog'], number]> = [ [vi.fn(async () => ({ ok: false as const, reason: 'config_conflict' as const })), 409], [vi.fn(async () => ({ ok: false as const, reason: 'validation_failed' as const, fieldErrors: [{ path: 'defaultConsumerIds', message: '未知 id' }], })), 422], - [vi.fn(async () => ({ ok: false as const, reason: 'bot_not_in_config' as const })), 404], [vi.fn(async () => ({ ok: false as const, reason: 'config_unavailable' as const })), 503], ]; - for (const [updateSnapshot, status] of failures) { - const deps = makeDeps({ updateSnapshot }); + for (const [updateCatalog, status] of failures) { + const deps = makeDeps({ updateCatalog }); const out = await handleVcMeetingConsumerProfilesPut(putRequest(), deps); expect(out.status).toBe(status); if (status === 422 && out.status === 422) { @@ -611,13 +555,15 @@ describe('handleVcMeetingConsumerProfilesPut', () => { } }); - it('success returns the fresh snapshot and hot-reloads the listener daemon', async () => { + // 共享目录走 daemon 侧 mtime 缓存的 live 读,下一个会议事件自然生效: + // 只保存预设时不该给任何 daemon 发 reload。 + it('success returns the fresh snapshot and reloads nobody when only the catalog changed', async () => { const deps = makeDeps(); const out = await handleVcMeetingConsumerProfilesPut(putRequest(), deps); expect(out.status).toBe(200); if (out.status !== 200) return; expect(out.body.revision).toBe('sha256:rev2'); - expect(deps.reloadDaemons).toHaveBeenCalledWith(['app_listener']); + expect(deps.reloadDaemons).toHaveBeenCalledWith([]); }); it('reload failure does not fail the PUT (config already persisted)', async () => { @@ -625,4 +571,133 @@ describe('handleVcMeetingConsumerProfilesPut', () => { const out = await handleVcMeetingConsumerProfilesPut(putRequest(), deps); expect(out.status).toBe(200); }); + + it('applies per-bot output-policy patches through the locked RMW dep and reloads those bots', async () => { + const deps = makeDeps(); + const out = await handleVcMeetingConsumerProfilesPut(putRequest({ + botOutputPolicies: [{ + appId: 'app_agent', + vcEnabled: false, + textOutputPolicy: 'approval', + voiceOutputPolicy: null, + realtimeVoiceEnabled: true, + }], + }), deps); + expect(out.status).toBe(200); + expect(deps.applyBotOutputPolicy).toHaveBeenCalledWith({ + appId: 'app_agent', + vcEnabled: false, + textOutputPolicy: 'approval', + voiceOutputPolicy: null, + realtimeVoiceEnabled: true, + catalogDefaultConsumerId: null, + }); + expect(deps.reloadDaemons).toHaveBeenCalledWith(['app_agent']); + }); + + // 老客户端不发 vcEnabled:缺省必须是「保持接收」,绝不能被解释成关掉这个 bot。 + it('defaults a missing vcEnabled to true instead of silently disabling the bot', async () => { + const deps = makeDeps(); + await handleVcMeetingConsumerProfilesPut(putRequest({ + botOutputPolicies: [{ + appId: 'app_agent', + textOutputPolicy: null, + voiceOutputPolicy: null, + realtimeVoiceEnabled: false, + }], + }), deps); + expect(deps.applyBotOutputPolicy).toHaveBeenCalledWith( + expect.objectContaining({ appId: 'app_agent', vcEnabled: true }), + ); + }); + + it('422 rejects a non-boolean vcEnabled before writing anything', async () => { + const deps = makeDeps(); + const out = await handleVcMeetingConsumerProfilesPut(putRequest({ + botOutputPolicies: [{ + appId: 'app_agent', + vcEnabled: 'yes', + textOutputPolicy: null, + voiceOutputPolicy: null, + realtimeVoiceEnabled: false, + }], + }), deps); + expect(out.status).toBe(422); + if (out.status !== 422) return; + expect(out.body.fieldErrors?.map(err => err.path)).toEqual(['botOutputPolicies[0].vcEnabled']); + expect(deps.updateCatalog).not.toHaveBeenCalled(); + expect(deps.applyBotOutputPolicy).not.toHaveBeenCalled(); + }); + + it('422 rejects unknown appId / bad policy values in botOutputPolicies before writing anything', async () => { + const deps = makeDeps(); + const out = await handleVcMeetingConsumerProfilesPut(putRequest({ + botOutputPolicies: [ + { appId: 'app_ghost', textOutputPolicy: 'allow', voiceOutputPolicy: null, realtimeVoiceEnabled: false }, + { appId: 'app_agent', textOutputPolicy: 'shout', voiceOutputPolicy: null, realtimeVoiceEnabled: false }, + ], + }), deps); + expect(out.status).toBe(422); + if (out.status !== 422) return; + expect(out.body.fieldErrors?.map(err => err.path)).toEqual([ + 'botOutputPolicies[0].appId', + 'botOutputPolicies[1].textOutputPolicy', + ]); + expect(deps.updateCatalog).not.toHaveBeenCalled(); + expect(deps.applyBotOutputPolicy).not.toHaveBeenCalled(); + }); + + it('503 bot_policy_write_failed when the locked RMW write fails (snapshot already committed, UI re-GETs)', async () => { + const deps = makeDeps({ + applyBotOutputPolicy: vi.fn(async () => ({ ok: false, reason: 'bot_not_in_config' })), + }); + const out = await handleVcMeetingConsumerProfilesPut(putRequest({ + botOutputPolicies: [{ + appId: 'app_agent', + vcEnabled: true, + textOutputPolicy: null, + voiceOutputPolicy: 'deny', + realtimeVoiceEnabled: false, + }], + }), deps); + expect(out.status).toBe(503); + if (out.status !== 503) return; + expect(out.body.error).toBe('bot_policy_write_failed'); + // Reload still ran so daemons converge on whatever actually landed. + expect(deps.reloadDaemons).toHaveBeenCalledWith(['app_agent']); + }); + + it('agent options expose configured + effective output policies (realtime voice on by default)', () => { + const bot = { + larkAppId: 'app_agent', + cliId: 'claude', + vcMeetingAgent: { + meetingConsumer: { textOutputPolicy: 'approval' }, + realtimeVoice: { enabled: true }, + }, + } as unknown as BotConfig; + const deps = makeDeps({ loadBotConfigs: vi.fn(() => [bot]) }); + const [option] = buildVcMeetingAgentOptions(deps); + expect(option).toMatchObject({ + textOutputPolicy: 'approval', + voiceOutputPolicy: null, + realtimeVoiceEnabled: true, + effectiveTextOutputPolicy: 'approval', + effectiveVoiceOutputPolicy: 'allow', + }); + }); + + it('an explicit realtimeVoice.enabled=false opts a bot out (voice denied)', () => { + const bot = { + larkAppId: 'app_agent', + cliId: 'claude', + vcMeetingAgent: { realtimeVoice: { enabled: false } }, + } as unknown as BotConfig; + const deps = makeDeps({ loadBotConfigs: vi.fn(() => [bot]) }); + const [option] = buildVcMeetingAgentOptions(deps); + expect(option).toMatchObject({ + realtimeVoiceEnabled: false, + effectiveVoiceOutputPolicy: 'deny', + }); + }); }); diff --git a/test/dashboard-vc-consumer-profiles-ui.test.ts b/test/dashboard-vc-consumer-profiles-ui.test.ts index 73122f003..b6870d82d 100644 --- a/test/dashboard-vc-consumer-profiles-ui.test.ts +++ b/test/dashboard-vc-consumer-profiles-ui.test.ts @@ -1,6 +1,6 @@ import React from 'react'; import TestRenderer, { act } from 'react-test-renderer'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { VcConsumerProfilesGate, VcConsumerProfilesSection, @@ -30,47 +30,74 @@ function jsonRes(status: number, body: Json) { return { ok: status >= 200 && status < 300, status, json: async () => body }; } +/** 共享目录里的预设**不带执行方**——DTO 里根本没有 agentAppId 这个字段。 */ function profileDto(id: string, over: Json = {}): Json { - return { id, agentAppId: 'app_agent', responseMode: 'silent', permissionPreset: 'observe_only', ...over }; + return { id, responseMode: 'silent', permissionPreset: 'observe_only', ...over }; } -function catalogBody(bot: string, over: Json = {}): Json { +function agentOption(appId: string, over: Json = {}): Json { + return { + appId, + label: appId, + online: true, + workingDirReady: true, + reliableTurnTerminal: true, + managedSideEffectEligible: true, + sandboxIsolated: true, + vcEnabled: true, + vcEligible: true, + textOutputPolicy: null, + voiceOutputPolicy: null, + realtimeVoiceEnabled: false, + effectiveTextOutputPolicy: 'allow', + effectiveVoiceOutputPolicy: 'deny', + ...over, + }; +} + +function catalogBody(over: Json = {}): Json { return { ok: true, - listenerBotAppId: bot, - revision: `rev-${bot}-1`, + revision: 'rev-1', catalogState: 'profiles', defaultMode: 'listenOnly', defaultConsumerIds: [], - profiles: [profileDto(`${bot}-profile`)], - agentOptions: [{ - appId: 'app_agent', label: 'Agent', online: true, workingDirReady: true, reliableTurnTerminal: true, - managedSideEffectEligible: true, sandboxIsolated: true, - }], + profiles: [profileDto('minutes', { label: '会议纪要' })], + agentOptions: [agentOption('app_alpha', { label: 'Bot Alpha' })], templateCatalog: VC_MEETING_CONSUMER_PROFILE_TEMPLATE_CATALOG, ...over, }; } -const TWO_BOTS = [ - { larkAppId: 'A', botName: 'Bot A' }, - { larkAppId: 'B', botName: 'Bot B' }, -]; +const CONSUMER_PROFILES_URL = '/api/vc-meeting/consumer-profiles'; +const PREFLIGHT_URL = '/api/vc-meeting/bot-preflight'; -function sectionProps(over: Json = {}) { - return { - canWrite: true, - listenerBotAppId: 'A', - listenerBotOptions: TWO_BOTS, - ...over, - }; +/** 路由式 fetch stub:GET 目录 / PUT 保存 / POST preflight 各自可注入。 */ +function stubFetch(opts: { + onGet?: (nth: number) => unknown; + onPut?: (body: Json) => unknown; + onPost?: (body: Json) => unknown; +} = {}): ReturnType { + let gets = 0; + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + const body = init?.body ? JSON.parse(String(init.body)) as Json : {}; + if (method === 'PUT') { + return (opts.onPut ?? (() => jsonRes(200, catalogBody({ revision: 'rev-2' }))))(body); + } + if (method === 'POST') return (opts.onPost ?? (() => jsonRes(200, { ok: true })))(body); + gets += 1; + return (opts.onGet ?? (() => jsonRes(200, catalogBody())))(gets); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; } async function mount(over: Json = {}): Promise { let renderer!: TestRenderer.ReactTestRenderer; await act(async () => { renderer = TestRenderer.create( - React.createElement(VcConsumerProfilesSection, sectionProps(over) as never), + React.createElement(VcConsumerProfilesSection, { canWrite: true, ...over } as never), ); }); return renderer; @@ -99,11 +126,11 @@ function textInputs(r: TestRenderer.ReactTestRenderer): TestRenderer.ReactTestIn } /** 详情弹窗里固定两个文本框(id、label)。 */ -function idInput(r: TestRenderer.ReactTestRenderer, _card = 0): TestRenderer.ReactTestInstance { +function idInput(r: TestRenderer.ReactTestRenderer): TestRenderer.ReactTestInstance { return textInputs(r)[0]; } -function labelInput(r: TestRenderer.ReactTestRenderer, _card = 0): TestRenderer.ReactTestInstance { +function labelInput(r: TestRenderer.ReactTestRenderer): TestRenderer.ReactTestInstance { return textInputs(r)[1]; } @@ -130,152 +157,189 @@ function saveButton(r: TestRenderer.ReactTestRenderer): TestRenderer.ReactTestIn return buttonByClass(r, 'vc-profiles-save'); } -/** 「新增预设」按钮:className 恰为 vc-profiles-link(remove/reload 带附加类)。 */ function addButton(r: TestRenderer.ReactTestRenderer): TestRenderer.ReactTestInstance | undefined { - return r.root.findAllByType('button') - .find(button => String(button.props.className ?? '').split(' ').includes('vc-profile-add')); + return buttonByClass(r, 'vc-profile-add'); +} + +function setInput(input: TestRenderer.ReactTestInstance, value: string): Promise { + return act(async () => { input.props.onChange({ target: { value }, currentTarget: { value } }); }); } -function optionButton( +/** 「设为默认」单选框:定位到卡片文本含 label 的那张卡。 */ +function defaultRadio( r: TestRenderer.ReactTestRenderer, label: string, ): TestRenderer.ReactTestInstance | undefined { - return r.root.findAllByType('button').find(button => textOf(button) === label); + const card = profileCards(r).find(node => textOf(node).includes(label)); + return card?.findAllByType('input').find(input => input.props.type === 'radio'); } -function clickOption(button: TestRenderer.ReactTestInstance): Promise { - return act(async () => { - button.props.onClick({ currentTarget: { closest: () => ({ removeAttribute: vi.fn() }) } }); +/** 点单选框:未选中走 onChange,已选中走 onClick(浏览器不会为同值再发 change)。 */ +async function clickDefaultRadio( + r: TestRenderer.ReactTestRenderer, label: string, +): Promise { + const radio = defaultRadio(r, label)!; + const wasChecked = radio.props.checked === true; + await act(async () => { + if (wasChecked) radio.props.onClick(); + else radio.props.onChange({ currentTarget: { checked: true }, target: { checked: true } }); }); } -function setInput(input: TestRenderer.ReactTestInstance, value: string): Promise { - return act(async () => { input.props.onChange({ target: { value }, currentTarget: { value } }); }); +function botRows(r: TestRenderer.ReactTestRenderer): TestRenderer.ReactTestInstance[] { + return r.root.findAllByProps({ className: 'vc-bot-policy-row' }); } -/** defaultConsumerIds 勾选框:文本恰为 profile id/label 的 vc-profile-check。 */ -function defaultConsumerCheckbox( +function botRow( r: TestRenderer.ReactTestRenderer, label: string, -): TestRenderer.ReactTestInstance | undefined { - const card = profileCards(r).find(node => textOf(node).includes(label)); - return card?.findAllByType('input').find(input => input.props.type === 'checkbox'); +): TestRenderer.ReactTestInstance { + const row = botRows(r).find(node => textOf(node).includes(label)); + if (!row) throw new Error(`bot policy row not found: ${label}`); + return row; } -function putCalls(fetchMock: ReturnType): Json[] { - return fetchMock.mock.calls - .filter(call => (call[1] as RequestInit | undefined)?.method === 'PUT') - .map(call => JSON.parse(String((call[1] as RequestInit).body)) as Json); +function rowCheckbox( + row: TestRenderer.ReactTestInstance, which: 'vcEnabled' | 'realtimeVoice', +): TestRenderer.ReactTestInstance { + const boxes = row.findAllByType('input').filter(input => input.props.type === 'checkbox'); + return which === 'vcEnabled' ? boxes[0] : boxes[1]; } -let confirmMock: ReturnType; +function rowSelect( + row: TestRenderer.ReactTestInstance, which: 'defaultProfile' | 'text' | 'voice', +): TestRenderer.ReactTestInstance { + // 每行的 select 顺序:默认角色 → 文字 → 语音。 + const selects = row.findAllByType('select'); + const index = which === 'defaultProfile' ? 0 : which === 'text' ? 1 : 2; + return selects[index]!; +} -beforeEach(() => { - confirmMock = vi.fn(() => true); - vi.stubGlobal('window', { confirm: confirmMock }); -}); +function preflightButton(row: TestRenderer.ReactTestInstance): TestRenderer.ReactTestInstance { + return row.findAllByType('button') + .find(button => String(button.props.className ?? '').split(' ').includes('vc-bot-policy-preflight-btn'))!; +} -afterEach(() => { - vi.unstubAllGlobals(); -}); +function callsByMethod( + fetchMock: ReturnType, method: string, +): Array<[string, RequestInit | undefined]> { + return fetchMock.mock.calls.filter((call) => { + const init = call[1] as RequestInit | undefined; + return (init?.method ?? 'GET') === method; + }) as Array<[string, RequestInit | undefined]>; +} -/** 简单场景通用 fetch:GET 按 bot 回 catalog,PUT 由调用方指定。 */ -function stubFetchImmediate( - catalogs: Record, - onPut: () => unknown = () => jsonRes(200, catalogBody('A')), -): ReturnType { - const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { - if (init?.method === 'PUT') return onPut(); - const bot = new URL(String(url), 'http://h').searchParams.get('listenerBotAppId') ?? ''; - const body = catalogs[bot]; - return body ? jsonRes(200, body) : jsonRes(404, { ok: false, error: 'bot_not_in_config' }); - }); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; +function putCalls(fetchMock: ReturnType): Json[] { + return callsByMethod(fetchMock, 'PUT').map(call => JSON.parse(String(call[1]!.body)) as Json); } -describe('VcConsumerProfilesSection · 加载与竞态', () => { - // codex 指定用例:A 慢 / B 快乱序——慢 A 响应绝不能覆盖已提交的 B catalog。 - it('discards a stale slow load(A) response after switching to B', async () => { - const gets = new Map(); - const fetchMock = vi.fn((url: string, init?: RequestInit) => { - if (init?.method === 'PUT') { - return Promise.resolve(jsonRes(200, catalogBody('B', { revision: 'rev-B-2' }))); - } - const bot = new URL(String(url), 'http://h').searchParams.get('listenerBotAppId') ?? ''; - const d = defer(); - gets.set(bot, d); - return d.promise; - }); - vi.stubGlobal('fetch', fetchMock); +afterEach(() => { + vi.unstubAllGlobals(); +}); +describe('VcConsumerProfilesSection · 共享目录(不再按 bot 配置)', () => { + it('loads one fleet-wide catalog with no listener selector and no per-bot configuring target', async () => { + const fetchMock = stubFetch(); const r = await mount(); - expect(gets.has('A')).toBe(true); - // 全局设置切到 B(未 dirty → 自动跟随),A 的 GET 仍悬挂 - await act(async () => { r.update(React.createElement(VcConsumerProfilesSection, sectionProps({ listenerBotAppId: 'B' }) as never)); }); - expect(gets.has('B')).toBe(true); - // B 先返回并提交 - gets.get('B')!.resolve(jsonRes(200, catalogBody('B'))); - await flush(); - await openProfile(r, 0); - expect(idInput(r, 0).props.value).toBe('B-profile'); + // 只有一个无参 GET:目录不再按 bot 分片,也就没有「配置所属 Listener」这一说。 + expect(callsByMethod(fetchMock, 'GET')).toHaveLength(1); + expect(fetchMock.mock.calls[0][0]).toBe(CONSUMER_PROFILES_URL); - // 慢 A 随后返回:token 已过期,必须被丢弃 - gets.get('A')!.resolve(jsonRes(200, catalogBody('A'))); - await flush(); - expect(idInput(r, 0).props.value).toBe('B-profile'); - expect(saveButton(r)).toBeTruthy(); - expect(saveButton(r)!.props.disabled).toBe(true); // 未 dirty + const section = r.root.findByProps({ className: 'vc-profiles-section' }); + expect(textOf(section)).toContain('这份预设由所有 bot 共享'); + expect(textOf(section)).not.toContain('正在配置'); + expect(r.root.findAllByProps({ className: 'vc-profile-config-target' })).toHaveLength(0); + expect(r.root.findAllByProps({ 'aria-label': '配置所属 Listener' })).toHaveLength(0); + expect(r.root.findAllByProps({ 'aria-label': '会议事件接收 Bot' })).toHaveLength(0); + }); - // 接着编辑并保存:PUT 必须归属 B 的 catalog(forBot + B 的 revision), - // 证明 stale A 既没污染渲染也没污染保存目标。 - await setInput(labelInput(r, 0), 'edited-on-B'); + it('never writes an execution bot into a saved profile', async () => { + // 回归:预设过去把执行方 agentAppId 焊死在条目里,播种兜底还会写进**另一个** + // bot 的 appId——「拉 A 进会却把 B 拉进监听群」。现在 DTO 里没有这个字段, + // 执行方在读路径绑定为收到会议事件的那个 bot 自己。 + const fetchMock = stubFetch(); + const r = await mount(); + await clickDefaultRadio(r, '会议纪要'); await act(async () => { saveButton(r)!.props.onClick(); }); await flush(); - const puts = putCalls(fetchMock); - expect(puts).toHaveLength(1); - expect(puts[0].listenerBotAppId).toBe('B'); - expect(puts[0].expectedRevision).toBe('rev-B-1'); + + const put = putCalls(fetchMock)[0]; + expect(put).toMatchObject({ + expectedRevision: 'rev-1', + defaultMode: 'agents', + defaultConsumerIds: ['minutes'], + }); + const profiles = put.profiles as Json[]; + expect(profiles).toHaveLength(1); + expect(Object.keys(profiles[0])).not.toContain('agentAppId'); + expect(JSON.stringify(put)).not.toContain('agentAppId'); }); +}); +describe('VcConsumerProfilesSection · 加载', () => { it('renders no editor (and no save button) while a load is pending', async () => { - const d = defer(); - vi.stubGlobal('fetch', vi.fn(() => d.promise)); + const pending = defer(); + stubFetch({ onGet: () => pending.promise }); const r = await mount(); expect(saveButton(r)).toBeUndefined(); expect(textInputs(r)).toHaveLength(0); - d.resolve(jsonRes(200, catalogBody('A'))); + expect(botRows(r)).toHaveLength(0); + pending.resolve(jsonRes(200, catalogBody())); await flush(); expect(saveButton(r)).toBeTruthy(); }); - it('blocks props-driven auto-follow while dirty, then converges after saving', async () => { - const fetchMock = stubFetchImmediate({ A: catalogBody('A'), B: catalogBody('B') }); + it('surfaces a load failure and mounts no editor', async () => { + stubFetch({ onGet: () => jsonRes(503, { ok: false, error: 'config_unavailable' }) }); + const r = await mount(); + expect(r.root.findAllByProps({ className: 'hint-warn' }) + .some(node => textOf(node).includes('config_unavailable'))).toBe(true); + expect(saveButton(r)).toBeUndefined(); + }); + + it('discards a superseded load response when the reload button is double-clicked', async () => { + // 目录没有 bot 选择器后,唯一能并发触发两次 load 的入口是冲突横幅的「重新加载」。 + // 慢的那次响应回来时 token 已过期,绝不能覆盖新响应(也不能污染保存目标)。 + const reloads: Deferred[] = []; + const fetchMock = stubFetch({ + onGet: (nth) => { + if (nth === 1) return jsonRes(200, catalogBody()); + const d = defer(); + reloads.push(d); + return d.promise; + }, + onPut: () => jsonRes(409, { ok: false, error: 'config_conflict' }), + }); const r = await mount(); await openProfile(r, 0); - await setInput(labelInput(r, 0), 'edited'); + await setInput(labelInput(r), 'edited'); + await act(async () => { saveButton(r)!.props.onClick(); }); + await flush(); - // props 变更:dirty → 不自动跟随,不发 GET B - await act(async () => { r.update(React.createElement(VcConsumerProfilesSection, sectionProps({ listenerBotAppId: 'B' }) as never)); }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(idInput(r, 0).props.value).toBe('A-profile'); + const reload = r.root.findAllByProps({ className: 'hint-warn' })[0].findByType('button'); + await act(async () => { reload.props.onClick(); reload.props.onClick(); }); + expect(reloads).toHaveLength(2); + + reloads[1].resolve(jsonRes(200, catalogBody({ + revision: 'rev-fresh', profiles: [profileDto('fresh', { label: '最新目录' })], + }))); + await flush(); + reloads[0].resolve(jsonRes(200, catalogBody({ + revision: 'rev-stale', profiles: [profileDto('stale', { label: '过期目录' })], + }))); + await flush(); - // 先保存 A 的待提交修改;dirty 清除后,显式 listener B 必须自动接管, - // 否则页面没有 catalog selector,会永久困在旧 listener。 + expect(textOf(r.root.findByProps({ className: 'vc-profile-card-grid' }))).toContain('最新目录'); + expect(textOf(r.root.findByProps({ className: 'vc-profile-card-grid' }))).not.toContain('过期目录'); + await clickDefaultRadio(r, '最新目录'); await act(async () => { saveButton(r)!.props.onClick(); }); await flush(); - expect(putCalls(fetchMock)[0]?.listenerBotAppId).toBe('A'); - expect(fetchMock.mock.calls.some(call => - !(call[1] as RequestInit | undefined)?.method - && new URL(String(call[0]), 'http://h').searchParams.get('listenerBotAppId') === 'B')) - .toBe(true); - await openProfile(r, 0); - expect(idInput(r, 0).props.value).toBe('B-profile'); + expect(putCalls(fetchMock).at(-1)?.expectedRevision).toBe('rev-fresh'); }); }); -describe('VcConsumerProfilesSection · Listener 归属与语义文案', () => { +describe('VcConsumerProfilesSection · 预设编辑', () => { it('shows the built-in library and copies templates into detached editable profiles', async () => { - const fetchMock = stubFetchImmediate({ A: catalogBody('A') }); + const fetchMock = stubFetch(); const r = await mount(); expect(r.root.findAllByProps({ className: 'vc-profile-template-card' })).toHaveLength(5); @@ -283,17 +347,19 @@ describe('VcConsumerProfilesSection · Listener 归属与语义文案', () => { expect(textOf(r.root)).toContain('会议主持'); expect(textOf(r.root)).toContain('方案评审与风险挑战'); expect(textOf(r.root)).toContain('访谈与需求洞察'); - expect(textOf(r.root)).not.toContain('不联网、不上报使用数据'); - const openFirstTemplate = async () => { - await act(async () => { r.root.findAllByProps({ className: 'vc-profile-template-card' })[0].props.onClick(); }); + const useFirstTemplate = async () => { + await act(async () => { + r.root.findAllByProps({ className: 'vc-profile-template-card' })[0].props.onClick(); + }); await act(async () => { buttonByClass(r, 'vc-profile-template-use')!.props.onClick(); }); }; - await openFirstTemplate(); + await useFirstTemplate(); expect(idInput(r).props.value).toBe('important-sync'); expect(labelInput(r).props.value).toBe('会议重要信息同步'); expect(r.root.findByType('textarea').props.value).toContain('时间、负责人、范围、状态或结论的修正'); await closeProfile(r); - await openFirstTemplate(); + // 第二次用同一个模板:id 自动去重,不会撞上刚建的那条。 + await useFirstTemplate(); expect(idInput(r).props.value).toBe('important-sync-2'); await act(async () => { saveButton(r)!.props.onClick(); }); @@ -301,190 +367,218 @@ describe('VcConsumerProfilesSection · Listener 归属与语义文案', () => { const created = (putCalls(fetchMock)[0].profiles as Json[])[1]; expect(created).toMatchObject({ id: 'important-sync', - agentAppId: 'app_agent', responseMode: 'listener_thread', listenerPlacement: 'topic', permissionPreset: 'observe_only', activityTypes: ['transcript_received', 'chat_received'], }); + expect(Object.keys(created)).not.toContain('agentAppId'); }); - it('renders an explicit listener as a read-only configuring target', async () => { - stubFetchImmediate({ A: catalogBody('A') }); + it('keeps the default role single-select and clears it when the selected one is clicked again', async () => { + const fetchMock = stubFetch({ + onGet: () => jsonRes(200, catalogBody({ + profiles: [profileDto('minutes', { label: '会议纪要' }), profileDto('scribe', { label: '速记' })], + })), + }); const r = await mount(); - expect(textOf(r.root.findByProps({ className: 'vc-profile-config-target' }))) - .toBe('正在配置:Bot A'); - expect(r.root.findAllByProps({ 'aria-label': '配置所属 Listener' })).toHaveLength(0); - expect(optionButton(r, 'Bot B')).toBeUndefined(); - }); - - it('shows the labeled listener selector only in auto mode and keeps dirty-switch confirmation', async () => { - const fetchMock = stubFetchImmediate({ A: catalogBody('A'), B: catalogBody('B') }); - const r = await mount({ listenerBotAppId: null }); + await clickDefaultRadio(r, '会议纪要'); + expect(defaultRadio(r, '会议纪要')!.props.checked).toBe(true); + expect(defaultRadio(r, '速记')!.props.checked).toBe(false); - expect(r.root.findAllByProps({ 'aria-label': '配置所属 Listener' })).toHaveLength(1); - expect(textOf(r.root.findByProps({ className: 'vc-profiles-section' }))) - .toContain('配置所属 Listener'); + // 换一个:单选,前一个自动取消——一个 bot 进会只跑一个角色。 + await clickDefaultRadio(r, '速记'); + expect(defaultRadio(r, '会议纪要')!.props.checked).toBe(false); + expect(defaultRadio(r, '速记')!.props.checked).toBe(true); - await openProfile(r, 0); - await setInput(labelInput(r, 0), 'edited'); - confirmMock.mockReturnValueOnce(false); - await clickOption(optionButton(r, 'Bot B')!); - expect(confirmMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(labelInput(r, 0).props.value).toBe('edited'); - - await clickOption(optionButton(r, 'Bot B')!); + // 再点一次已选中的:回到仅监听。 + await clickDefaultRadio(r, '速记'); + expect(defaultRadio(r, '速记')!.props.checked).toBe(false); + await act(async () => { saveButton(r)!.props.onClick(); }); await flush(); - expect(fetchMock).toHaveBeenCalledTimes(2); - await openProfile(r, 0); - expect(idInput(r, 0).props.value).toBe('B-profile'); - }); - - it('uses disambiguated Bot and no-action labels in both locales', () => { - const zh = createDashboardTranslator('zh'); - const en = createDashboardTranslator('en'); - - expect(zh('settings.vcMeetingListenerBot')).toBe('会议事件接收 Bot'); - expect(en('settings.vcMeetingListenerBot')).toBe('Meeting event receiver Bot'); - expect(zh('settings.vcProfiles.fieldAgent')).toBe('角色执行 Bot'); - expect(en('settings.vcProfiles.fieldAgent')).toBe('Role execution Bot'); - expect(zh('settings.vcProfiles.defaultMode')).toContain('未操作'); - expect(en('settings.vcProfiles.defaultMode')).toContain('no selection'); - expect(zh('settings.vcProfiles.defaultConsumers')).toContain('未操作'); - expect(en('settings.vcProfiles.defaultConsumers')).toContain('no selection'); - expect(zh('settings.vcProfiles.migrationOffer')).toContain('会中文字和语音必须经过受管输出闸门'); - expect(zh('settings.vcProfiles.migrationOffer')).toContain('语音还需 Listener 语音设施已启用'); - expect(en('settings.vcProfiles.migrationOffer')).toContain('listener-thread replies can be sent directly'); - expect(en('settings.vcProfiles.migrationOffer')).toContain('managed output gate'); - expect(en('settings.vcProfiles.migrationOffer')).toContain('requires approval by default'); - expect(zh('settings.vcProfiles.migrationEnable')).toContain('升级并启用全能力默认纪要'); - expect(en('settings.vcProfiles.migrationEnable')).toContain('Upgrade and enable full-capability minutes'); + expect(putCalls(fetchMock)[0]).toMatchObject({ defaultMode: 'listenOnly', defaultConsumerIds: [] }); }); -}); -describe('VcConsumerProfilesSection · 保存', () => { - it('offers the exact legacy seed as an explicit full-capability v2 upgrade and saves through the existing PUT', async () => { - const v2Instructions = '持续整理会议纪要,重点记录已确认的决策、待办事项(含负责人和截止时间)以及未解决风险;字幕修订时更新已有条目,不重复记录同一事项。仅在出现新的关键决策、明确待办或风险,或被用户点名时,才在监听群输出简洁增量;无实质增量时保持静默,不发送确认或心跳。需要向会议内发送文字或语音时,必须通过 botmux 受管 request-output/action gate 提交,不得绕过权限、所有权与审核策略。'; - const fetchMock = stubFetchImmediate({ - A: catalogBody('A', { - migrationOffer: 'enable_seeded_minutes_default', - profiles: [profileDto('minutes', { - agentAppId: 'app_agent', - label: '会议纪要', - instructions: 'legacy instructions', - activityTypes: ['transcript_received'], - })], - }), - }, () => jsonRes(200, catalogBody('A', { - revision: 'rev-A-2', - defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - profiles: [profileDto('minutes', { - label: '会议纪要', - instructions: v2Instructions, - activityTypes: ['transcript_received'], - responseMode: 'listener_thread', - permissionPreset: 'meeting_text_voice', - })], - }))); + it('follows an id rename into defaultConsumerIds and falls back to listen-only when the default is removed', async () => { + const fetchMock = stubFetch(); const r = await mount(); - expect(textOf(r.root)).toContain('监听群回复可直接发送'); - expect(textOf(r.root)).toContain('会中文字和语音必须经过受管输出闸门'); - const enable = optionButton(r, '升级并启用全能力默认纪要(保存后生效)'); - expect(enable).toBeTruthy(); - await act(async () => { enable!.props.onClick(); }); - expect(defaultConsumerCheckbox(r, '会议纪要')?.props.checked).toBe(true); - expect(saveButton(r)?.props.disabled).toBe(false); + + await act(async () => { addButton(r)!.props.onClick(); }); + await setInput(idInput(r), 'draft'); + await clickDefaultRadio(r, 'draft'); + await setInput(idInput(r), 'draft-renamed'); await act(async () => { saveButton(r)!.props.onClick(); }); await flush(); expect(putCalls(fetchMock)[0]).toMatchObject({ defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - profiles: [{ - id: 'minutes', - agentAppId: 'app_agent', - label: '会议纪要', - instructions: v2Instructions, - activityTypes: ['transcript_received'], - responseMode: 'listener_thread', - permissionPreset: 'meeting_text_voice', - }], + defaultConsumerIds: ['draft-renamed'], }); - expect(optionButton(r, '升级并启用全能力默认纪要(保存后生效)')).toBeUndefined(); + expect((putCalls(fetchMock)[0].profiles as Json[]).map(p => p.id)).toEqual(['minutes', 'draft-renamed']); + + // 删掉那条默认角色:ids 空了就必须退回 listenOnly,否则 agents + 空 ids 会在 + // 保存时被服务端拒绝,用户要到 422 才知道。 + await act(async () => { addButton(r)!.props.onClick(); }); + await setInput(idInput(r), 'temp'); + await clickDefaultRadio(r, 'temp'); + await act(async () => { buttonByClass(r, 'vc-profile-remove')!.props.onClick(); }); + await act(async () => { saveButton(r)!.props.onClick(); }); + await flush(); + expect(putCalls(fetchMock).at(-1)).toMatchObject({ defaultMode: 'listenOnly', defaultConsumerIds: [] }); + + // 把默认角色的 id 清空同理:清空那一刻默认角色就没了,后面再补一个新 id 也不会 + // 自动跟回来(ids 已空),此时若还留着 agents 就是同一个必吃 422 的组合。 + await act(async () => { addButton(r)!.props.onClick(); }); + await setInput(idInput(r), 'blanked'); + await clickDefaultRadio(r, 'blanked'); + await setInput(idInput(r), ''); + await setInput(idInput(r), 'typed-again'); + await act(async () => { saveButton(r)!.props.onClick(); }); + await flush(); + expect(putCalls(fetchMock).at(-1)).toMatchObject({ defaultMode: 'listenOnly', defaultConsumerIds: [] }); }); +}); - it('shows an actionable reason when an uninitialized catalog has no eligible execution bot', async () => { - stubFetchImmediate({ - A: catalogBody('A', { - catalogState: 'uninitialized', - profiles: [], - agentOptions: [{ - appId: 'broken', label: 'Broken', online: true, workingDirReady: false, reliableTurnTerminal: false, - managedSideEffectEligible: false, sandboxIsolated: false, - }], - }), - }); +describe('VcConsumerProfilesSection · 按 bot 的会议开关', () => { + const THREE_BOTS = [ + agentOption('app_off', { label: 'Bot Beta', vcEnabled: false }), + agentOption('app_api', { + label: 'Bot Gamma', vcEligible: false, vcEnabled: false, workingDirReady: false, + }), + agentOption('app_on', { label: 'Bot Alpha' }), + ]; + + it('lists every bot, enabled first, and disables what an apiOnly bot cannot use', async () => { + stubFetch({ onGet: () => jsonRes(200, catalogBody({ agentOptions: THREE_BOTS })) }); const r = await mount(); - expect(r.root.findAllByProps({ className: 'hint-warn' }) - .some(node => textOf(node).includes('暂时无法生成默认角色'))).toBe(true); + + expect(botRows(r)).toHaveLength(3); + // 接收会议事件的排前面,其余按名字——整个 fleet 都在表里,不用先去别处开开关。 + expect(botRows(r).findIndex(row => textOf(row).includes('Bot Alpha'))).toBe(0); + + const gamma = botRow(r, 'Bot Gamma'); + // 能力缺口收成一个 ⚠,详情在 title(hover 可见),不再平铺整行长文案。 + const gammaWarn = gamma.findAllByProps({ className: 'vc-bot-policy-warn' }) + .find(node => textOf(node) === '⚠'); + expect(gammaWarn?.props.title).toContain('无飞书连接(apiOnly),收不到会议事件'); + expect(rowCheckbox(gamma, 'vcEnabled').props.disabled).toBe(true); + expect(preflightButton(gamma).props.disabled).toBe(true); + + // 关掉「接收会议事件」的 bot:会中输出策略无从谈起,一并禁用。 + const beta = botRow(r, 'Bot Beta'); + expect(rowSelect(beta, 'text').props.disabled).toBe(true); + expect(rowCheckbox(beta, 'realtimeVoice').props.disabled).toBe(true); + expect(textOf(beta)).toContain('不接收会议事件'); + + const alpha = botRow(r, 'Bot Alpha'); + expect(rowSelect(alpha, 'text').props.disabled).toBe(false); + expect(preflightButton(alpha).props.disabled).toBe(false); }); - it('save posts catalog.forBot + revision; id rename & card removal preserve valid multiple defaults', async () => { - const fetchMock = stubFetchImmediate({ - A: catalogBody('A', { - defaultMode: 'agents', - defaultConsumerIds: ['minutes', 'scribe'], - profiles: [profileDto('minutes'), profileDto('scribe')], - }), - }, () => jsonRes(200, catalogBody('A', { revision: 'rev-A-2' }))); + it('filters the bot rows by the search box (name or appId)', async () => { + stubFetch({ onGet: () => jsonRes(200, catalogBody({ agentOptions: THREE_BOTS })) }); const r = await mount(); + expect(botRows(r)).toHaveLength(3); - // 删除 scribe 卡:defaultConsumerIds 同步剔除 'scribe' - await openProfile(r, 1); - await act(async () => { buttonByClass(r, 'vc-profile-remove')!.props.onClick(); }); + const search = r.root.findAllByType('input').find(i => i.props.className === 'vc-bot-policy-search')!; + await setInput(search, 'Gamma'); + expect(botRows(r)).toHaveLength(1); + expect(textOf(botRows(r)[0]!)).toContain('Bot Gamma'); - // 新增预设 → 起名 a → 勾为默认:保留已有默认;改名 b 后默认 id 跟着更新。 - await act(async () => { addButton(r)!.props.onClick(); }); - await setInput(idInput(r), 'a'); + // 按 appId 也能命中。 + await setInput(search, 'app_on'); + expect(botRows(r)).toHaveLength(1); + expect(textOf(botRows(r)[0]!)).toContain('Bot Alpha'); + + // 无命中给空态提示。 + await setInput(search, 'zzz-nope'); + expect(botRows(r)).toHaveLength(0); + + // 清空恢复全部。 + await setInput(search, ''); + expect(botRows(r)).toHaveLength(3); + }); + + it('submits only the bot rows whose policy actually changed', async () => { + const fetchMock = stubFetch({ onGet: () => jsonRes(200, catalogBody({ agentOptions: THREE_BOTS })) }); + const r = await mount(); + + const alpha = botRow(r, 'Bot Alpha'); + await act(async () => { + rowSelect(alpha, 'text').props.onChange({ target: { value: 'approval' } }); + }); await act(async () => { - defaultConsumerCheckbox(r, 'a')!.props.onChange({ currentTarget: { checked: true } }); + rowCheckbox(botRow(r, 'Bot Alpha'), 'realtimeVoice').props.onChange({ target: { checked: true } }); }); - expect(defaultConsumerCheckbox(r, 'minutes')?.props.checked).toBe(true); - expect(defaultConsumerCheckbox(r, 'a')?.props.checked).toBe(true); - await setInput(idInput(r), 'b'); + await act(async () => { saveButton(r)!.props.onClick(); }); + await flush(); + + expect(putCalls(fetchMock)[0].botOutputPolicies).toEqual([{ + appId: 'app_on', + vcEnabled: true, + textOutputPolicy: 'approval', + voiceOutputPolicy: null, + realtimeVoiceEnabled: true, + catalogDefaultConsumerId: null, + }]); + }); + + it('submits a per-bot default role picked from the shared catalog', async () => { + const fetchMock = stubFetch({ onGet: () => jsonRes(200, catalogBody({ agentOptions: THREE_BOTS })) }); + const r = await mount(); + await act(async () => { + rowSelect(botRow(r, 'Bot Alpha'), 'defaultProfile').props.onChange({ target: { value: 'minutes' } }); + }); await act(async () => { saveButton(r)!.props.onClick(); }); await flush(); - const puts = putCalls(fetchMock); - expect(puts).toHaveLength(1); - expect(puts[0].listenerBotAppId).toBe('A'); - expect(puts[0].expectedRevision).toBe('rev-A-1'); - expect(puts[0].defaultConsumerIds).toEqual(['minutes', 'b']); - expect((puts[0].profiles as Json[]).map(p => p.id)).toEqual(['minutes', 'b']); + expect(putCalls(fetchMock)[0].botOutputPolicies).toEqual([{ + appId: 'app_on', + vcEnabled: true, + textOutputPolicy: null, + voiceOutputPolicy: null, + realtimeVoiceEnabled: false, + catalogDefaultConsumerId: 'minutes', + }]); + }); + + it('drops a row from the patch when it is edited back to its loaded value', async () => { + const fetchMock = stubFetch({ onGet: () => jsonRes(200, catalogBody({ agentOptions: THREE_BOTS })) }); + const r = await mount(); + + await act(async () => { + rowSelect(botRow(r, 'Bot Alpha'), 'voice').props.onChange({ target: { value: 'deny' } }); + }); + await act(async () => { + rowSelect(botRow(r, 'Bot Alpha'), 'voice').props.onChange({ target: { value: 'default' } }); + }); + await act(async () => { saveButton(r)!.props.onClick(); }); + await flush(); + expect(putCalls(fetchMock)[0].botOutputPolicies).toEqual([]); }); - // codex 指定用例:PUT pending 时全部编辑入口冻结(含 add/remove), - // 成功响应整份替换 catalog 时不存在可被吞掉的进行中编辑。 it('freezes every edit control while a save is pending, unfreezes on success', async () => { const put = defer(); - stubFetchImmediate({ A: catalogBody('A') }, () => put.promise); + stubFetch({ + onGet: () => jsonRes(200, catalogBody({ agentOptions: THREE_BOTS })), + onPut: () => put.promise, + }); const r = await mount(); await openProfile(r, 0); - await setInput(labelInput(r, 0), 'edited'); + await setInput(labelInput(r), 'edited'); await act(async () => { saveButton(r)!.props.onClick(); }); - // pending:输入框/textarea/checkbox/add/remove 全部 disabled for (const input of r.root.findAllByType('input')) { expect(input.props.disabled).toBe(true); } + for (const select of r.root.findAllByType('select')) { + expect(select.props.disabled).toBe(true); + } expect(r.root.findByType('textarea').props.disabled).toBe(true); expect(addButton(r)!.props.disabled).toBe(true); expect(buttonByClass(r, 'vc-profile-remove')!.props.disabled).toBe(true); expect(saveButton(r)!.props.disabled).toBe(true); - // 下拉全冻结:卡片内 agent/responseMode/preset/defaultMode(显式 Listener 只读) const menus = r.root.findAllByType('details'); expect(menus.length).toBeGreaterThan(0); for (const menu of menus) { @@ -492,25 +586,83 @@ describe('VcConsumerProfilesSection · 保存', () => { expect(menu.findByType('summary').props['aria-disabled']).toBe(true); } - put.resolve(jsonRes(200, catalogBody('A', { - revision: 'rev-A-2', - profiles: [profileDto('A-profile', { label: 'edited' })], + put.resolve(jsonRes(200, catalogBody({ + revision: 'rev-2', + agentOptions: THREE_BOTS, + profiles: [profileDto('minutes', { label: 'edited' })], }))); await flush(); await openProfile(r, 0); - expect(labelInput(r, 0).props.disabled).toBe(false); - expect(labelInput(r, 0).props.value).toBe('edited'); + expect(labelInput(r).props.disabled).toBe(false); + expect(labelInput(r).props.value).toBe('edited'); expect(saveButton(r)!.props.disabled).toBe(true); // 保存后回到未 dirty }); +}); + +describe('VcConsumerProfilesSection · 配置权限(preflight)', () => { + const TWO_BOTS = [ + agentOption('app_on', { label: 'Bot Alpha' }), + agentOption('app_two', { label: 'Bot Delta' }), + ]; + + it('posts the bot appId, blocks a concurrent run, and keeps unsaved policy edits', async () => { + const pending = defer(); + const posted: Json[] = []; + const fetchMock = stubFetch({ + onGet: () => jsonRes(200, catalogBody({ agentOptions: TWO_BOTS })), + onPost: (body) => { posted.push(body); return pending.promise; }, + }); + const qr = vi.fn(); + const r = await mount({ onFeishuLoginQr: qr }); + + // 先做一处没保存的策略编辑:preflight 成功后刻意不 reload,否则会冲掉它。 + await act(async () => { + rowSelect(botRow(r, 'Bot Alpha'), 'text').props.onChange({ target: { value: 'deny' } }); + }); + await act(async () => { preflightButton(botRow(r, 'Bot Alpha')).props.onClick(); }); + expect(posted).toEqual([{ appId: 'app_on' }]); + expect(callsByMethod(fetchMock, 'POST')[0][0]).toBe(PREFLIGHT_URL); + // 开放平台会话是共享的,一次只允许跑一个:所有行的按钮都禁掉。 + expect(preflightButton(botRow(r, 'Bot Alpha')).props.disabled).toBe(true); + expect(preflightButton(botRow(r, 'Bot Delta')).props.disabled).toBe(true); + expect(textOf(botRow(r, 'Bot Alpha'))).toContain('配置中…'); + + pending.resolve(jsonRes(200, { ok: true })); + await flush(); + expect(textOf(botRow(r, 'Bot Alpha'))).toContain('权限与事件订阅已就绪'); + expect(qr).toHaveBeenCalledWith(null); + expect(callsByMethod(fetchMock, 'GET')).toHaveLength(1); // 没有 reload + expect(rowSelect(botRow(r, 'Bot Alpha'), 'text').props.value).toBe('deny'); + expect(saveButton(r)!.props.disabled).toBe(false); // 未保存的编辑还在 + expect(preflightButton(botRow(r, 'Bot Delta')).props.disabled).toBe(false); + }); + + it('surfaces the login QR and the error text when preflight fails', async () => { + stubFetch({ + onGet: () => jsonRes(200, catalogBody({ agentOptions: TWO_BOTS })), + onPost: () => jsonRes(200, { + ok: false, error: 'feishu_login_required', feishuLoginQr: 'data:image/png;base64,QR', + }), + }); + const qr = vi.fn(); + const r = await mount({ onFeishuLoginQr: qr }); + + await act(async () => { preflightButton(botRow(r, 'Bot Alpha')).props.onClick(); }); + await flush(); + expect(qr).toHaveBeenCalledWith('data:image/png;base64,QR'); + const failure = botRow(r, 'Bot Alpha').findAllByProps({ className: 'vc-bot-policy-warn' }); + expect(failure.some(node => textOf(node) === 'feishu_login_required')).toBe(true); + expect(preflightButton(botRow(r, 'Bot Alpha')).props.disabled).toBe(false); // 可重试 + }); +}); + +describe('VcConsumerProfilesSection · 保存失败', () => { it('409 shows the conflict banner, disables save, and reload recovers', async () => { - const fetchMock = stubFetchImmediate( - { A: catalogBody('A') }, - () => jsonRes(409, { ok: false, error: 'config_conflict' }), - ); + const fetchMock = stubFetch({ onPut: () => jsonRes(409, { ok: false, error: 'config_conflict' }) }); const r = await mount(); await openProfile(r, 0); - await setInput(labelInput(r, 0), 'edited'); + await setInput(labelInput(r), 'edited'); await act(async () => { saveButton(r)!.props.onClick(); }); await flush(); @@ -518,28 +670,26 @@ describe('VcConsumerProfilesSection · 保存', () => { expect(banner).toBeTruthy(); expect(saveButton(r)!.props.disabled).toBe(true); - // 冲突横幅里的「重新加载」→ 重新 GET,冲突清除 const reload = banner.findByType('button'); await act(async () => { reload.props.onClick(); }); await flush(); - expect(fetchMock.mock.calls.filter(c => !(c[1] as RequestInit | undefined)?.method).length).toBe(2); + expect(callsByMethod(fetchMock, 'GET')).toHaveLength(2); expect(r.root.findAllByProps({ className: 'hint-warn' })).toHaveLength(0); await openProfile(r, 0); - expect(labelInput(r, 0).props.value).toBe(''); // 服务端版本,丢弃本地冲突稿 + expect(labelInput(r).props.value).toBe('会议纪要'); // 服务端版本,丢弃本地冲突稿 }); it('422 renders fieldErrors inline at the addressed input', async () => { - stubFetchImmediate( - { A: catalogBody('A') }, - () => jsonRes(422, { + stubFetch({ + onPut: () => jsonRes(422, { ok: false, error: 'validation_failed', fieldErrors: [{ path: 'profiles[0].id', message: 'id 与在会成员冲突' }], }), - ); + }); const r = await mount(); await openProfile(r, 0); - await setInput(labelInput(r, 0), 'edited'); + await setInput(labelInput(r), 'edited'); await act(async () => { saveButton(r)!.props.onClick(); }); await flush(); @@ -547,66 +697,6 @@ describe('VcConsumerProfilesSection · 保存', () => { expect(errors.some(node => textOf(node) === 'id 与在会成员冲突')).toBe(true); expect(saveButton(r)!.props.disabled).toBe(false); // 仍 dirty,可改后重试 }); - - it('seeds a NEW profile with the first SELECTABLE agent, not the disabled appId-sorted first one', async () => { - // Regression: addProfile used to seed agentOptions[0] blindly. If the - // appId-sorted first bot is disabled (un-spawnable), that hardcoded default - // bypassed the dropdown's disable and the server PUT (which only checks the - // id is a non-empty string) happily persisted an agent that never replies. - const fetchMock = stubFetchImmediate({ - A: catalogBody('A', { - catalogState: 'profiles', - profiles: [], - // Sorted first is disabled; the eligible one sorts later. - agentOptions: [ - { - appId: 'aaa_broken', label: 'Broken', online: true, workingDirReady: false, - reliableTurnTerminal: true, managedSideEffectEligible: false, sandboxIsolated: false, - }, - { - appId: 'zzz_good', label: 'Good', online: true, workingDirReady: true, - reliableTurnTerminal: true, managedSideEffectEligible: true, sandboxIsolated: true, - }, - ], - }), - }, () => jsonRes(200, catalogBody('A', { revision: 'rev-A-2' }))); - const r = await mount(); - - await act(async () => { addButton(r)!.props.onClick(); }); - await setInput(idInput(r), 'seeded'); - await act(async () => { saveButton(r)!.props.onClick(); }); - await flush(); - - const puts = putCalls(fetchMock); - expect(puts).toHaveLength(1); - const seeded = (puts[0].profiles as Json[]).find(p => p.id === 'seeded'); - // NOT 'aaa_broken' (the disabled [0]); the first selectable agent instead. - expect(seeded?.agentAppId).toBe('zzz_good'); - }); - - it('disables the Add button when no agent is structurally eligible', async () => { - stubFetchImmediate({ - A: catalogBody('A', { - catalogState: 'profiles', - profiles: [], - agentOptions: [{ - appId: 'only_broken', label: 'Broken', online: true, workingDirReady: false, - reliableTurnTerminal: false, managedSideEffectEligible: false, sandboxIsolated: false, - }], - }), - }); - const r = await mount(); - // No selectable agent ⇒ Add is disabled (can't seed a replying consumer). - expect(addButton(r)!.props.disabled).toBe(true); - - // The template "Use" button must be disabled for the same reason — applying - // a template also seeds an agent, so it can't be a second way to create an - // un-spawnable profile when nothing is eligible. - await act(async () => { - r.root.findAllByProps({ className: 'vc-profile-template-card' })[0].props.onClick(); - }); - expect(buttonByClass(r, 'vc-profile-template-use')!.props.disabled).toBe(true); - }); }); describe('VcConsumerProfilesGate · 私有端点挂载门', () => { @@ -616,7 +706,7 @@ describe('VcConsumerProfilesGate · 私有端点挂载门', () => { let r!: TestRenderer.ReactTestRenderer; await act(async () => { r = TestRenderer.create(React.createElement(VcConsumerProfilesGate, { - enabled: true, canWrite: false, listenerBotAppId: 'A', listenerBotOptions: TWO_BOTS, + enabled: true, canWrite: false, })); }); expect(fetchMock).not.toHaveBeenCalled(); @@ -625,23 +715,46 @@ describe('VcConsumerProfilesGate · 私有端点挂载门', () => { }); it('disabled feature renders nothing; canWrite=true mounts the editor', async () => { - const fetchMock = stubFetchImmediate({ A: catalogBody('A') }); + const fetchMock = stubFetch(); let r!: TestRenderer.ReactTestRenderer; await act(async () => { r = TestRenderer.create(React.createElement(VcConsumerProfilesGate, { - enabled: false, canWrite: true, listenerBotAppId: 'A', listenerBotOptions: TWO_BOTS, + enabled: false, canWrite: true, })); }); expect(r.toJSON()).toBeNull(); expect(fetchMock).not.toHaveBeenCalled(); await act(async () => { - r.update(React.createElement(VcConsumerProfilesGate, { - enabled: true, canWrite: true, listenerBotAppId: 'A', listenerBotOptions: TWO_BOTS, - })); + r.update(React.createElement(VcConsumerProfilesGate, { enabled: true, canWrite: true })); }); await flush(); expect(fetchMock).toHaveBeenCalledTimes(1); expect(r.root.findAllByProps({ className: 'vc-profiles-section' }).length).toBeGreaterThan(0); }); }); + +describe('会议角色预设 · 文案', () => { + it('retires the per-bot listener wording and states the shared contract in both locales', () => { + const zh = createDashboardTranslator('zh'); + const en = createDashboardTranslator('en'); + + // 退役的按-bot 措辞:翻译缺失时 translator 原样回 key。 + expect(zh('settings.vcMeetingListenerBot')).toBe('settings.vcMeetingListenerBot'); + expect(en('settings.vcMeetingListenerBot')).toBe('settings.vcMeetingListenerBot'); + expect(zh('settings.vcProfiles.fieldAgent')).toBe('settings.vcProfiles.fieldAgent'); + + expect(zh('settings.vcProfiles.sharedNotice')).toContain('谁被拉进会议,就由谁执行'); + expect(en('settings.vcProfiles.sharedNotice')).toContain('shared by every bot'); + expect(zh('settings.vcProfiles.defaultSingleHint')).toContain('默认角色只能有一个'); + expect(en('settings.vcProfiles.defaultSingleHint')).toContain('Only one default role'); + expect(zh('settings.vcProfiles.botPolicies.title')).toBe('按 Bot 的会议开关'); + expect(en('settings.vcProfiles.botPolicies.title')).toBe('Per-bot meeting switches'); + expect(zh('settings.vcProfiles.botPolicies.vcIneligible')).toContain('apiOnly'); + expect(en('settings.vcProfiles.botPolicies.vcIneligible')).toContain('apiOnly'); + expect(zh('settings.vcProfiles.botPolicies.preflightHelp')).toContain('订阅会议事件'); + expect(zh('settings.vcProfiles.botPolicies.preflightHelp')).toContain('启动时自动体检'); + expect(en('settings.vcProfiles.botPolicies.preflightHelp')).toContain('subscribe meeting events'); + expect(en('settings.vcProfiles.botPolicies.preflightHelp')).toContain('auto-checked at daemon startup'); + }); +}); diff --git a/test/dispatch.test.ts b/test/dispatch.test.ts index dc6ab75c3..252ba2e87 100644 --- a/test/dispatch.test.ts +++ b/test/dispatch.test.ts @@ -480,7 +480,7 @@ describe('send-target reachability helpers', () => { })).toEqual(new Set()); }); - it('excludes isolated deferred and VC chat sessions from the ordinary routing slot', async () => { + it('excludes isolated deferred schedule-run chat sessions from the ordinary routing slot', async () => { expect(await foldableChatSessionAppIds({ sessions: [ { @@ -491,6 +491,21 @@ describe('send-target reachability helpers', () => { larkAppId: 'cli_deferred', deferredScheduleRun: { routingAnchor: 'schedule-run:1' }, }, + ], + targetChatId: 'oc_main', + outboundMode: 'plain', + resolveMode: () => 'chat', + resolveChatMode: async () => 'group', + })).toEqual(new Set()); + }); + + it('Plan B: a VC meeting-agent chat session IS foldable via the ordinary slot', async () => { + // Under Plan B the meeting agent is an ordinary chat-scope session, so a + // mention in its listener group must fold back into it like any other + // chat-scope peer — the vcMeetingReceiver marker is delivery metadata and + // no longer excludes the session from the foldable set. + expect(await foldableChatSessionAppIds({ + sessions: [ { status: 'active', scope: 'chat', @@ -504,7 +519,7 @@ describe('send-target reachability helpers', () => { outboundMode: 'plain', resolveMode: () => 'chat', resolveChatMode: async () => 'group', - })).toEqual(new Set()); + })).toEqual(new Set(['cli_vc'])); }); it('fails closed after a regular group becomes a topic chat', async () => { diff --git a/test/recall-frozen-cards.test.ts b/test/recall-frozen-cards.test.ts index a7f85bab4..90b62bdad 100644 --- a/test/recall-frozen-cards.test.ts +++ b/test/recall-frozen-cards.test.ts @@ -405,11 +405,14 @@ describe('restoreUsageLimitRuntimeState', () => { expect(persistStreamCardStateMock).toHaveBeenCalledWith(ds); }); - it('updates receiver retry state without patching a Lark card', () => { + it('Plan B: a meeting-agent session patches its Lark card on retry-ready like a normal session', () => { const now = new Date('2026-05-22T10:00:00Z').getTime(); vi.useFakeTimers(); vi.setSystemTime(now); const ds = makeDs(); + // The vcMeetingReceiver marker is now pure delivery metadata — it no longer + // suppresses the streaming card, so a meeting agent's usage-limit card patch + // proceeds exactly like any ordinary chat-scope session. ds.session.vcMeetingReceiver = { listenerAppId: 'listener-app', meetingId: 'meeting-1', memberId: 'member-1', memberEpoch: 1, @@ -429,24 +432,28 @@ describe('restoreUsageLimitRuntimeState', () => { expect(ds.usageLimit.retryReady).toBe(true); expect(persistStreamCardStateMock).toHaveBeenCalledWith(ds); - expect(buildStreamingCard).not.toHaveBeenCalled(); - expect(updateMessageMock).not.toHaveBeenCalled(); + // Card patch now proceeds (no VC suppression): the retry-ready state reaches + // the live card the same way it does for a normal session. + expect(updateMessageMock).toHaveBeenCalled(); }); }); -describe('receiver streaming card boundary', () => { - it('refuses a fresh group-visible streaming card for a dedicated receiver', async () => { +describe('meeting-agent streaming card (Plan B)', () => { + it('posts a fresh group-visible streaming card for a meeting-agent session', async () => { const ds = makeDs(); + // Under Plan B the meeting agent is an ordinary chat-scope session, so its + // streaming card surfaces like any group session (the "看不到流式卡片" fix). ds.session.vcMeetingReceiver = { listenerAppId: 'listener-app', meetingId: 'meeting-1', memberId: 'member-1', memberEpoch: 1, }; ds.workerPort = 4567; - const sessionReply = vi.fn(async () => 'om_forbidden'); + ds.workerReady = true; + const sessionReply = vi.fn(async () => 'om_card'); - await expect(postFreshStreamingCard(ds, sessionReply)).resolves.toBe(false); - expect(sessionReply).not.toHaveBeenCalled(); - expect(buildStreamingCard).not.toHaveBeenCalled(); + await expect(postFreshStreamingCard(ds, sessionReply)).resolves.toBe(true); + expect(sessionReply).toHaveBeenCalled(); + expect(buildStreamingCard).toHaveBeenCalled(); }); }); diff --git a/test/scope-optional-autofix.test.ts b/test/scope-optional-autofix.test.ts index 574f872cd..06d244da6 100644 --- a/test/scope-optional-autofix.test.ts +++ b/test/scope-optional-autofix.test.ts @@ -88,3 +88,57 @@ describe('tryAutoFixScopes — silent / disableQrLogin plumbing', () => { expect(adminIdx).toBeGreaterThan(silentIdx); }); }); + +describe('ensureVcMeetingEventsSubscribed — startup VC-event check-then-configure', () => { + const region = fnRegion('export async function ensureVcMeetingEventsSubscribed(', 3200); + + it('skips non-feishu, apiOnly, and VC-inactive bots (active-config gate)', () => { + expect(region).toContain("if (brand !== 'feishu') return;"); + // vcMeetingAgentConfigActive fail-closes apiOnly AND enabled:false, so this + // one guard covers both "no Feishu VC" cases. + expect(region).toContain('if (!vcMeetingAgentConfigActive(bot.config)) return;'); + }); + + it('probes read-only FIRST, then only auto-subscribes when events are missing', () => { + const probeIdx = region.indexOf('await probeVcMeetingEventSubscription(larkAppId)'); + const gateIdx = region.indexOf('probe.missingVcEvents.length === 0 && probe.eventModeReady'); + const automationIdx = region.indexOf('await automateOpenPlatformSetup('); + expect(probeIdx).toBeGreaterThanOrEqual(0); + // the "already subscribed → return" gate sits BETWEEN the probe and the + // publishing automation, so a satisfied bot never republishes. + expect(gateIdx).toBeGreaterThan(probeIdx); + expect(automationIdx).toBeGreaterThan(gateIdx); + }); + + it('never pops a QR at boot (disableQrLogin into the publishing automation)', () => { + expect(region).toContain('disableQrLogin: true,'); + }); + + it('degrades gracefully when the probe fails (log, no throw, no QR)', () => { + // probe.ok === false → info log + early return BEFORE any automation call + const probeFailIdx = region.indexOf('if (!probe.ok) {'); + const automationIdx = region.indexOf('await automateOpenPlatformSetup('); + expect(probeFailIdx).toBeGreaterThanOrEqual(0); + expect(probeFailIdx).toBeLessThan(automationIdx); + expect(region).toContain('botmux setup'); + }); + + it('DMs the admin only when the auto-subscribe actually fails', () => { + const failIdx = region.indexOf('VC event auto-subscribe failed'); + const dmIdx = region.indexOf('await dmAdmin('); + expect(failIdx).toBeGreaterThanOrEqual(0); + expect(dmIdx).toBeGreaterThan(failIdx); + }); +}); + +describe('daemon startup wires the VC-event check behind !cfg.apiOnly', () => { + const daemonSrc = readFileSync(new URL('../src/daemon.ts', import.meta.url), 'utf-8'); + + it('calls ensureVcMeetingEventsSubscribed non-blocking inside the !cfg.apiOnly block', () => { + const guardIdx = daemonSrc.indexOf('checkRequiredScopes(cfg.larkAppId).catch'); + const vcIdx = daemonSrc.indexOf('ensureVcMeetingEventsSubscribed(cfg.larkAppId).catch'); + expect(guardIdx).toBeGreaterThanOrEqual(0); + // sits right after the scope check, sharing the same !cfg.apiOnly gate + expect(vcIdx).toBeGreaterThan(guardIdx); + }); +}); diff --git a/test/session-lifecycle-start.test.ts b/test/session-lifecycle-start.test.ts index 15c40981d..70372c868 100644 --- a/test/session-lifecycle-start.test.ts +++ b/test/session-lifecycle-start.test.ts @@ -2842,15 +2842,17 @@ describe('worker startup failure delivery', () => { expect(sessionReply).not.toHaveBeenCalled(); }); - it('keeps a VC receiver IM-turn fork error out of auxiliary Lark UI', async () => { + it('Plan B: a plain user IM-turn fork error on a meeting-agent session DOES surface to the user', async () => { const sessionReply = vi.fn(async () => 'om_error_reply'); initWorkerPool({ sessionReply, getSessionWorkingDir: () => '/repo', getActiveCount: () => 1, closeSession: vi.fn() }); const ds = makeDs(); (ds.session as unknown as { vcMeetingReceiver: unknown }).vcMeetingReceiver = { meetingId: 'm1', memberId: 'mem1', memberEpoch: 1, }; - // A listener-group @agent IM turn has no durable dispatchAttempt, but a - // startup diagnostic is not the exact authorized reply action. + // A plain listener-group user turn has NO durable dispatchAttempt and NO + // stamped meeting @mention origin → it is not meeting-driven, so its worker + // fork failure must reach the user like any ordinary session (the whole point + // of Plan B: the user's own turns are answered, not silently swallowed). forkWorker(ds, 'deliver', { turnId: 'im-turn' }); const worker = forkMock.mock.results.at(-1)!.value; @@ -2858,6 +2860,26 @@ describe('worker startup failure delivery', () => { await Promise.resolve(); await Promise.resolve(); + expect(sessionReply).toHaveBeenCalled(); + }); + + it('Plan B: a durable meeting-delivery fork error stays fenced (not surfaced out-of-band)', async () => { + const sessionReply = vi.fn(async () => 'om_error_reply'); + initWorkerPool({ sessionReply, getSessionWorkingDir: () => '/repo', getActiveCount: () => 1, closeSession: vi.fn() }); + const ds = makeDs(); + (ds.session as unknown as { vcMeetingReceiver: unknown }).vcMeetingReceiver = { + meetingId: 'm1', memberId: 'mem1', memberEpoch: 1, + }; + // A durable transcript delivery (dispatchAttempt set) IS meeting-driven, so a + // fork error is fenced to the receipt/lease chain and must never leak + // out-of-band (it could post on a silent delivery). + forkWorker(ds, 'deliver', { turnId: 'vc-delivery', dispatchAttempt: 3 }); + const worker = forkMock.mock.results.at(-1)!.value; + + worker.emit('error', new Error('spawn ENOENT')); + await Promise.resolve(); + await Promise.resolve(); + expect(sessionReply).not.toHaveBeenCalled(); }); diff --git a/test/session-reply-thread-anchor.test.ts b/test/session-reply-thread-anchor.test.ts index 26f4993b6..bf5373142 100644 --- a/test/session-reply-thread-anchor.test.ts +++ b/test/session-reply-thread-anchor.test.ts @@ -260,6 +260,21 @@ describe('sessionReply chat-scope chokepoint — shared fold-back anchoring', () expect(receiver.session.sessionId).not.toBe(ordinary.session.sessionId); }); + it('Plan B: a meeting-agent session is keyed at the ordinary chat slot, not an isolated vc-receiver key', () => { + // The one-line root cause of the "meeting listener totally broken" report: + // activeSessionKey used to key a vcMeetingReceiver session by + // `vc-receiver:${sessionId}`, splitting it into a second routing universe so + // plain IM (keyed by the chat anchor) could never reach it. Under Plan B the + // marker is pure delivery metadata and the session lives at the normal + // (chatId, appId) slot — so IM and transcripts fold into the SAME session. + const receiver = seedReceiverSession(); + expect(activeSessionKey(receiver)).toBe(sessionKey(CHAT, APP)); + expect(activeSessionKey(receiver)).not.toContain('vc-receiver:'); + // The map slot the meeting agent occupies IS the ordinary chat key, so an + // inbound message to this chat resolves this exact session. + expect(activeSessions.get(sessionKey(CHAT, APP))).toBe(receiver); + }); + it('keeps receiver hook attribution when no ordinary chat session exists', async () => { const receiver = seedReceiverSession(); diff --git a/test/session-resume.test.ts b/test/session-resume.test.ts index 9463a5373..0c04f3000 100644 --- a/test/session-resume.test.ts +++ b/test/session-resume.test.ts @@ -253,7 +253,12 @@ describe('resumeSession', () => { if (!r.ok) expect(r.error).toBe('adopt_unsupported'); }); - it('rejects manual resume for a closed dedicated VC receiver without mutating its state or routing map', async () => { + it('Plan B: a closed meeting-agent session resumes as an ordinary chat session (no vc_receiver_managed refusal)', async () => { + // Under Plan B a meeting agent is an ordinary chat-scope session, so a + // closed one is resumable like any chat session — the vc_receiver_managed + // refusal is gone. Here the chat anchor is already held by a live ordinary + // chat, so the resume correctly falls through to the ordinary + // anchor-occupancy guard instead of a VC-specific refusal. const receiver = makeClosedSession({ chatId: 'oc_listener', rootMessageId: 'oc_listener', @@ -281,7 +286,10 @@ describe('resumeSession', () => { const r = await resumeSession(receiver.sessionId, map); - expect(r).toEqual({ ok: false, error: 'vc_receiver_managed' }); + // No VC-specific refusal — the ordinary anchor-occupancy guard wins because + // a live chat session already owns this chat's slot. + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe('anchor_occupied'); expect(sessionStore.getSession(receiver.sessionId)?.status).toBe('closed'); expect(map.size).toBe(1); expect(map.get(ordinaryChatKey)).toBe(ordinaryChat); @@ -614,7 +622,7 @@ describe('resumeSession', () => { expect(result.ds.pendingRepo).toBeFalsy(); }); - it('restores dedicated VC receivers without collapsing them into the ordinary chat slot', async () => { + it('Plan B: collapses legacy dedicated VC receiver rows into the ordinary chat slot on restore', async () => { const make = (title: string, receiver?: { meetingId: string; memberId: string }) => { const s = sessionStore.createSession('oc_listener', 'oc_listener', title, 'group'); s.larkAppId = 'app_test'; @@ -632,6 +640,13 @@ describe('resumeSession', () => { sessionStore.updateSession(s); return s; }; + // A plain IM session and two legacy dedicated-receiver rows, all for the + // same listener chat. Under the old model these lived at three distinct + // `vc-receiver:` keys; under Plan B a meeting agent is an ordinary + // chat-scope session, so all three resolve to the SAME (chatId, appId) + // slot. The restore CAS keeps the first-priority winner and closes the + // duplicate losers — the authoritative meeting lifecycle later re-ensures a + // fresh binding at that same ordinary slot. const ordinary = make('ordinary chat'); const meetingA = make('meeting A', { meetingId: 'meeting-a', memberId: 'member-a' }); const meetingB = make('meeting B', { meetingId: 'meeting-b', memberId: 'member-b' }); @@ -639,12 +654,53 @@ describe('resumeSession', () => { await restoreActiveSessions(map); - expect(map.get(sessionKey('oc_listener', 'app_test'))?.session.sessionId).toBe(ordinary.sessionId); - expect(map.get(sessionKey(`vc-receiver:${meetingA.sessionId}`, 'app_test'))?.session.sessionId) - .toBe(meetingA.sessionId); - expect(map.get(sessionKey(`vc-receiver:${meetingB.sessionId}`, 'app_test'))?.session.sessionId) - .toBe(meetingB.sessionId); - expect(map.size).toBe(3); + // Exactly one active-map entry, at the ordinary chat key — no `vc-receiver:` + // isolated keys survive. + expect(map.size).toBe(1); + const winner = map.get(sessionKey('oc_listener', 'app_test')); + expect(winner).toBeDefined(); + expect([...map.keys()].some(k => k.includes('vc-receiver:'))).toBe(false); + // The first same-priority row (the plain IM session) wins; the two + // duplicate meeting rows are closed as collision losers. + expect(winner?.session.sessionId).toBe(ordinary.sessionId); + expect(sessionStore.getSession(meetingA.sessionId)?.status).toBe('closed'); + expect(sessionStore.getSession(meetingB.sessionId)?.status).toBe('closed'); + }); + + it('Plan B: a lone legacy meeting row restores at the ordinary chat key with its marker intact', async () => { + // Migration compat: a legacy dedicated-receiver row with no competing chat + // session at its listener chat must restore cleanly into the ordinary + // (chatId, appId) slot (no vc-receiver: key), keeping vcMeetingReceiver as + // delivery metadata so the meeting lifecycle + ledger still resolve it by + // sessionId. No hard hash migration is needed — the sessionId is stable + // across the key change. + const s = sessionStore.createSession('oc_solo_listener', 'oc_solo_listener', 'meeting solo', 'group'); + s.larkAppId = 'app_test'; + s.scope = 'chat'; + s.cliId = 'claude-code'; + s.workingDir = '/tmp/proj'; + s.vcMeetingReceiver = { + listenerAppId: 'listener_app', + meetingId: 'meeting-solo', + memberId: 'member-solo', + memberEpoch: 3, + }; + sessionStore.updateSession(s); + const map = new Map(); + + await restoreActiveSessions(map); + + const restored = map.get(sessionKey('oc_solo_listener', 'app_test')); + expect(restored?.session.sessionId).toBe(s.sessionId); + expect([...map.keys()].some(k => k.includes('vc-receiver:'))).toBe(false); + // Marker retained as delivery metadata (not stripped) so meeting delivery + // still targets this exact session by id. + expect(restored?.session.vcMeetingReceiver).toMatchObject({ + meetingId: 'meeting-solo', + memberId: 'member-solo', + memberEpoch: 3, + }); + expect(sessionStore.getSession(s.sessionId)?.status).toBe('active'); }); it('keeps the row closed when a concurrent close cancels resume registration', async () => { diff --git a/test/settings-write-applier.test.ts b/test/settings-write-applier.test.ts index 075f8c3ff..a6fdeadb3 100644 --- a/test/settings-write-applier.test.ts +++ b/test/settings-write-applier.test.ts @@ -72,8 +72,6 @@ function makeDeps(overrides: Partial = {}): SettingsWr isAutoUpdateSupportedInstall: vi.fn(() => true), resolveDashboardSettings: vi.fn(() => settingsView), isLocale: ((v: unknown): v is 'zh' | 'en' => v === 'zh' || v === 'en'), - syncVcMeetingListenerBotConfig: vi.fn(async () => ({ ok: true as const })), - validateVcMeetingListenerBotAppId: vi.fn(async () => ({ ok: true as const })), validateCodexNotifierTargetBotAppId: vi.fn(async () => ({ ok: true as const })), validateHostOverloadAlertTargetBotAppId: vi.fn(async () => ({ ok: true as const })), installCodexNotifierHook: vi.fn(), @@ -243,27 +241,28 @@ describe('applySettingsWrite happy paths', () => { expect(deps.mergeGlobalConfig).toHaveBeenCalledWith({ vcMeetingAgent: { enabled: false } }); }); - it('validates then syncs vcMeetingAgent.listenerBotAppId before writing the selected bot', async () => { + // 全局「会议事件接收 Bot」pin 已退役:daemon 侧每个 VC-active 的 bot 各自处理收到 + // 的会议事件,没人再读 listenerBotAppId。写路径必须把历史残留擦掉,别在配置里留一 + // 个谁都不读、看着却像还生效的字段。 + it('erases a stale vcMeetingAgent.listenerBotAppId on the next write', async () => { const deps = makeDeps({ readGlobalConfig: vi.fn(() => ({ vcMeetingAgent: { enabled: true, listenerBotAppId: 'cli_old' } })), }); - const r = await applySettingsWrite({ vcMeetingAgent: { listenerBotAppId: ' cli_listener ' } }, deps); + const r = await applySettingsWrite({ vcMeetingAgent: { enabled: true } }, deps); expect(r.ok).toBe(true); - expect(deps.validateVcMeetingListenerBotAppId).toHaveBeenCalledWith('cli_listener'); - expect(deps.syncVcMeetingListenerBotConfig).toHaveBeenCalledWith('cli_listener', 'cli_old'); - expect(vi.mocked(deps.validateVcMeetingListenerBotAppId).mock.invocationCallOrder[0]) - .toBeLessThan(vi.mocked(deps.syncVcMeetingListenerBotConfig).mock.invocationCallOrder[0]); - expect(deps.mergeGlobalConfig).toHaveBeenCalledWith({ vcMeetingAgent: { enabled: true, listenerBotAppId: 'cli_listener' } }); + expect(deps.mergeGlobalConfig).toHaveBeenCalledWith({ vcMeetingAgent: { enabled: true } }); }); - it('clears vcMeetingAgent.listenerBotAppId without validating', async () => { + it('never resurrects the retired listener pin from a client-supplied patch', async () => { const deps = makeDeps({ - readGlobalConfig: vi.fn(() => ({ vcMeetingAgent: { enabled: true, listenerBotAppId: 'cli_listener' } })), + readGlobalConfig: vi.fn(() => ({ vcMeetingAgent: { enabled: true } })), }); - const r = await applySettingsWrite({ vcMeetingAgent: { listenerBotAppId: null } }, deps); + // 老前端(或手搓 POST)还在提交这个字段时,也不能把 pin 写回去。 + const r = await applySettingsWrite( + { vcMeetingAgent: { enabled: true, listenerBotAppId: 'cli_listener' } as any }, + deps, + ); expect(r.ok).toBe(true); - expect(deps.validateVcMeetingListenerBotAppId).not.toHaveBeenCalled(); - expect(deps.syncVcMeetingListenerBotConfig).toHaveBeenCalledWith(null, 'cli_listener'); expect(deps.mergeGlobalConfig).toHaveBeenCalledWith({ vcMeetingAgent: { enabled: true } }); }); @@ -527,37 +526,14 @@ describe('applySettingsWrite — validation errors', () => { expect(deps.mergeGlobalConfig).not.toHaveBeenCalled(); }); - it('rejects invalid vcMeetingAgent.listenerBotAppId', async () => { + // listenerBotAppId 退役后不再是可写字段:只带它的 patch 等于什么都没改,必须被 + // 当作空 patch 拒绝,而不是靠它触发一次全局写。 + it('rejects a vcMeetingAgent patch that carries only the retired listener pin', async () => { const deps = makeDeps(); - const r = await applySettingsWrite({ vcMeetingAgent: { listenerBotAppId: 123 } }, deps); - expect(r.ok).toBe(false); - if (r.ok) throw new Error('unreachable'); - expect(r.error).toBe('invalid_vcMeetingAgent_listenerBotAppId'); - expect(deps.mergeGlobalConfig).not.toHaveBeenCalled(); - }); - - it('rejects vcMeetingAgent.listenerBotAppId when validation fails', async () => { - const deps = makeDeps({ - validateVcMeetingListenerBotAppId: vi.fn(async () => ({ ok: false as const, error: 'vcMeetingAgent_listenerBot_missing_scopes: vc:meeting.bot.join:write' })), - }); - const r = await applySettingsWrite({ vcMeetingAgent: { listenerBotAppId: 'cli_bad' } }, deps); + const r = await applySettingsWrite({ vcMeetingAgent: { listenerBotAppId: 'cli_listener' } as any }, deps); expect(r.ok).toBe(false); if (r.ok) throw new Error('unreachable'); - expect(r.error).toBe('vcMeetingAgent_listenerBot_missing_scopes: vc:meeting.bot.join:write'); - expect(deps.syncVcMeetingListenerBotConfig).not.toHaveBeenCalled(); - expect(deps.mergeGlobalConfig).not.toHaveBeenCalled(); - }); - - it('rejects vcMeetingAgent.listenerBotAppId when per-bot defaults cannot be written', async () => { - const deps = makeDeps({ - syncVcMeetingListenerBotConfig: vi.fn(async () => ({ ok: false as const, error: 'vcMeetingAgent_listenerBot_config_write_failed: bot_not_in_config' })), - }); - const r = await applySettingsWrite({ vcMeetingAgent: { listenerBotAppId: 'cli_missing' } }, deps); - expect(r.ok).toBe(false); - if (r.ok) throw new Error('unreachable'); - expect(r.error).toBe('vcMeetingAgent_listenerBot_config_write_failed: bot_not_in_config'); - expect(deps.validateVcMeetingListenerBotAppId).toHaveBeenCalledWith('cli_missing'); - expect(deps.syncVcMeetingListenerBotConfig).toHaveBeenCalledWith('cli_missing', null); + expect(r.error).toBe('invalid_vcMeetingAgent_enabled'); expect(deps.mergeGlobalConfig).not.toHaveBeenCalled(); }); diff --git a/test/setup-open-platform-automation.test.ts b/test/setup-open-platform-automation.test.ts index aaf4aeb5e..b2ba3fd08 100644 --- a/test/setup-open-platform-automation.test.ts +++ b/test/setup-open-platform-automation.test.ts @@ -24,6 +24,7 @@ import { mapManifestScopesToOpenPlatformIds, parseSetupOpenPlatformAutoFlag, prepareFeishuWebSession, + probeVcMeetingEventSubscription, readStoredCookiesFromSessionFile, type StoredCookie, vcListenerEventGateError, @@ -678,6 +679,78 @@ describe('createFeishuOpenPlatformApp', () => { }); }); +describe('probeVcMeetingEventSubscription — read-only VC event check', () => { + // Serve the console page (CSRF) + the read-only event-state endpoint. The + // probe must NEVER hit any /update or /create endpoint — it only reads. + function makeFetch(subscribedEvents: string[], eventMode = 4): { fetchImpl: typeof fetch; mutatingCalls: string[] } { + const mutatingCalls: string[] = []; + const fetchImpl = (async (url: string | URL | Request) => { + const href = String(url); + // Cached-session validation probe (prepareFeishuWebSession → validateFeishuWebSession): + // non-login content marks the cookie jar valid so disableQrLogin reuses it. + if (href === 'https://ask.feishu.cn/') return new Response('ask home', { status: 200 }); + if (href.endsWith('/app') || href.endsWith('/app/')) { + return new Response('', { status: 200 }); + } + if (href.includes('/developers/v1/event/') && !href.includes('/update')) { + return Response.json({ code: 0, data: { eventMode, appEvents: subscribedEvents, userEvents: subscribedEvents } }); + } + // Anything that would mutate (event/update, app_version/create, publish/commit) + if (href.includes('/update') || href.includes('/create') || href.includes('/publish')) { + mutatingCalls.push(href); + return Response.json({ code: 0, data: {} }); + } + throw new Error(`unexpected url: ${href}`); + }) as typeof fetch; + return { fetchImpl, mutatingCalls }; + } + + it('reports zero missing when all VC events are subscribed and never mutates', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-vc-probe-ok-')); + const sessionFile = join(dir, 'feishu-session.json'); + writeStoredCookiesToSessionFile(sessionFile, [cookie()]); + const all = ['vc.bot.meeting_invited_v1', 'vc.bot.meeting_activity_v1', 'vc.bot.meeting_ended_v1', 'vc.meeting.participant_meeting_joined_v1']; + const { fetchImpl, mutatingCalls } = makeFetch(all); + const result = await probeVcMeetingEventSubscription('cli_probe', { sessionFilePath: sessionFile, fetchImpl }); + expect(result).toMatchObject({ ok: true, missingVcEvents: [], eventModeReady: true }); + expect(mutatingCalls).toEqual([]); // read-only: proves no publish/subscribe side effects + }); + + it('lists the missing VC events when only some are subscribed', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-vc-probe-missing-')); + const sessionFile = join(dir, 'feishu-session.json'); + writeStoredCookiesToSessionFile(sessionFile, [cookie()]); + const { fetchImpl } = makeFetch(['vc.bot.meeting_invited_v1']); // 3 of 4 missing + const result = await probeVcMeetingEventSubscription('cli_probe', { sessionFilePath: sessionFile, fetchImpl }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.missingVcEvents).toEqual([ + 'vc.bot.meeting_activity_v1', 'vc.bot.meeting_ended_v1', 'vc.meeting.participant_meeting_joined_v1', + ]); + } + }); + + it('flags eventModeReady=false when not on long-connection mode', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-vc-probe-mode-')); + const sessionFile = join(dir, 'feishu-session.json'); + writeStoredCookiesToSessionFile(sessionFile, [cookie()]); + const all = ['vc.bot.meeting_invited_v1', 'vc.bot.meeting_activity_v1', 'vc.bot.meeting_ended_v1', 'vc.meeting.participant_meeting_joined_v1']; + const { fetchImpl } = makeFetch(all, /* eventMode */ 0); + const result = await probeVcMeetingEventSubscription('cli_probe', { sessionFilePath: sessionFile, fetchImpl }); + expect(result).toMatchObject({ ok: true, missingVcEvents: [], eventModeReady: false }); + }); + + it('fails cleanly (no QR, no throw) when there is no cached web session', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-vc-probe-nosession-')); + const sessionFile = join(dir, 'feishu-session.json'); // never written + let qrShown = false; + const fetchImpl = (async () => { qrShown = true; throw new Error('should not fetch without a session'); }) as typeof fetch; + const result = await probeVcMeetingEventSubscription('cli_probe', { sessionFilePath: sessionFile, fetchImpl }); + expect(result.ok).toBe(false); + expect(qrShown).toBe(false); // disableQrLogin: no network / no QR when the cache is gone + }); +}); + describe('automateOpenPlatformSetup', () => { it('forwards forceQrLogin so configure --switch-account ignores a valid cache', async () => { const dir = mkdtempSync(join(tmpdir(), 'botmux-open-platform-auto-force-')); diff --git a/test/turn-reactions.test.ts b/test/turn-reactions.test.ts index fec5ac3b7..198bc5949 100644 --- a/test/turn-reactions.test.ts +++ b/test/turn-reactions.test.ts @@ -93,11 +93,14 @@ describe('two-phase turn reactions', () => { expect(ds.pendingAckReactions ?? []).toEqual([]); }); - it('dedicated VC receivers never add or finish progress reactions', async () => { + it('Plan B: a meeting-agent session reacts to plain user turns like any card-off session', async () => { registerWith(true); const ds = makeDs({ pendingAckReactions: [{ messageId: 'om_old', reactionId: 'rid_old' }], }); + // The vcMeetingReceiver marker is now pure delivery metadata. A plain user + // message (no stamped meeting @mention origin) is an ordinary turn, so it + // gets the ✋ on receipt and its pending ✋ settle to ✅ on idle. ds.session.vcMeetingReceiver = { listenerAppId: 'listener-app', meetingId: 'meeting-1', @@ -108,8 +111,9 @@ describe('two-phase turn reactions', () => { await noteTurnReceived(ds, 'om_new'); await finishTurnReactions(ds); - expect(mocks.addReaction).not.toHaveBeenCalled(); - expect(mocks.removeReaction).not.toHaveBeenCalled(); + // ✋ added on the new user message; the stale + new pending entries settle. + expect(mocks.addReaction).toHaveBeenCalled(); + expect(mocks.removeReaction).toHaveBeenCalled(); expect(ds.pendingAckReactions).toEqual([]); }); diff --git a/test/vc-meeting-action-gate.test.ts b/test/vc-meeting-action-gate.test.ts index 44034db25..9908aaf8d 100644 --- a/test/vc-meeting-action-gate.test.ts +++ b/test/vc-meeting-action-gate.test.ts @@ -313,6 +313,49 @@ describe('vc meeting managed action gate', () => { expect(gate.authorize).toHaveBeenCalledTimes(1); }); + it('Plan B: creates an in-meeting action on a just-COMPLETED delivery (idle-gap: agent speaks after the turn ends)', async () => { + // The facilitator decides to speak AFTER it finished processing the + // transcript turn, so the delivery receipt is `completed`, not `dispatched`. + // Completion is terminal (no attempt N+1 can take over), so the action gate + // must still admit the in-meeting output for the exact completed attempt. + expect(completeVcMeetingDelivery(dir, { + listenerAppId: LISTENER, + meetingId: MEETING, + memberId: MEMBER, + memberEpoch: 1, + deliveryKey: DELIVERY, + }, { workerGeneration: 4, dispatchAttempt: 1 }, 135)).toMatchObject({ + ok: true, + receipt: { status: 'completed', dispatchAttempt: 1 }, + }); + + const gate = deps(); + const result = await requestVcMeetingManagedAction(request({ dispatchAttempt: 1 }), gate, 140); + expect(result).toMatchObject({ + status: 202, + body: { kind: 'execute', action: { status: 'attempting' } }, + }); + expect(gate.authorize).toHaveBeenCalledTimes(1); + }); + + it('Plan B: still rejects a NON-terminal-non-dispatched receipt (failed/ambiguous) for a new action', async () => { + // The completed relaxation must not open failed/ambiguous receipts. + expect(markVcMeetingDeliveryAmbiguous(dir, { + listenerAppId: LISTENER, + meetingId: MEETING, + memberId: MEMBER, + memberEpoch: 1, + deliveryKey: DELIVERY, + }, { workerGeneration: 4, dispatchAttempt: 1 }, 135)).toMatchObject({ + ok: true, + receipt: { status: 'ambiguous' }, + }); + const gate = deps(); + const result = await requestVcMeetingManagedAction(request({ dispatchAttempt: 1 }), gate, 140); + expect(result).toMatchObject({ status: 409, body: { errorCode: 'delivery_not_dispatched' } }); + expect(gate.authorize).not.toHaveBeenCalled(); + }); + it('rejects a delivery from a superseded member epoch', async () => { expect(applyVcMeetingMemberProjection(dir, projection({ memberEpoch: 2, diff --git a/test/vc-meeting-consumer-profile-bootstrap.test.ts b/test/vc-meeting-consumer-profile-bootstrap.test.ts index a1abc5d6b..8c44cfa0f 100644 --- a/test/vc-meeting-consumer-profile-bootstrap.test.ts +++ b/test/vc-meeting-consumer-profile-bootstrap.test.ts @@ -1,365 +1,65 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('@larksuiteoapi/node-sdk', () => ({ - Client: class FakeClient {}, -})); - -const structuralDeps = { - workingDirReady: (bot: { workingDir?: string }) => !!bot.workingDir, - reliableTurnTerminal: (bot: { cliId?: string }) => bot.cliId === 'claude-code', - managedSideEffectEligible: () => true, - sandboxIsolated: () => true, -}; - -async function freshModules() { - vi.resetModules(); - const registry = await import('../src/bot-registry.js'); - const bootstrap = await import('../src/services/vc-meeting-consumer-profile-bootstrap.js'); - registry.loadBotConfigs(); - return { registry, bootstrap }; -} - -function bot( - larkAppId: string, - over: Record = {}, -): Record { - return { - larkAppId, - larkAppSecret: `secret-${larkAppId}`, - cliId: 'claude-code', - workingDir: `/work/${larkAppId}`, - ...over, - }; -} - -describe('lock-protected default VC consumer profile bootstrap', () => { - let configDir: string; - let configPath: string; - - beforeEach(() => { - configDir = mkdtempSync(join(tmpdir(), 'botmux-vc-profile-bootstrap-')); - configPath = join(configDir, 'bots.json'); - process.env.BOTS_CONFIG = configPath; - }); - - afterEach(() => { - delete process.env.BOTS_CONFIG; - rmSync(configDir, { recursive: true, force: true }); - }); - - function writeConfig(entries: Record[]): void { - writeFileSync(configPath, `${JSON.stringify(entries, null, 2)}\n`, 'utf8'); - } - - function readConfig(): any[] { - return JSON.parse(readFileSync(configPath, 'utf8')); - } - - function listener(consumer: Record = { enabled: true }): Record { - return bot('listener', { - vcMeetingAgent: { - enabled: true, - larkCliProfile: 'listener', - meetingConsumer: consumer, - }, - }); - } - - it('materializes an enabled minutes default on the eligible listener and remains parser-valid', async () => { - writeConfig([listener({ enabled: true, injectIntervalMs: 30_000 }), bot('agent_z'), bot('agent_a')]); - const { registry, bootstrap } = await freshModules(); - - const first = await bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps); - expect(first).toEqual({ ok: true, seeded: true, agentAppId: 'listener' }); - const raw = readConfig(); - expect(raw[0].vcMeetingAgent.meetingConsumer).toMatchObject({ - enabled: true, - injectIntervalMs: 30_000, - defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - defaultProfileBootstrap: { - generatorVersion: 2, - profileId: 'minutes', - configHash: expect.stringMatching(/^sha256:[0-9a-f]{64}$/u), - }, - consumerProfiles: [{ - id: 'minutes', - agentAppId: 'listener', - label: '会议纪要', - role: 'minutes', - responseMode: 'listener_thread', - capabilities: ['listener.output.request', 'meeting.output.request', 'meeting.read'], - ownedSinks: ['meeting_text', 'meeting_voice'], - }], - }); - expect(raw[0].vcMeetingAgent.meetingConsumer.consumerProfiles[0].instructions) - .toContain('无实质增量时保持静默'); - const consumer = raw[0].vcMeetingAgent.meetingConsumer; - expect(consumer.defaultProfileBootstrap.configHash).toBe( - bootstrap.computeVcMeetingDefaultConsumerProfileConfigHash({ - defaultMode: consumer.defaultMode, - defaultConsumerIds: consumer.defaultConsumerIds, - profile: consumer.consumerProfiles[0], - }), - ); - expect(bootstrap.isVcMeetingDefaultConsumerProfileBootstrapIntact(consumer)).toBe(true); - expect(registry.loadBotConfigs()[0].vcMeetingAgent?.meetingConsumer?.consumerProfiles?.[0]?.agentAppId) - .toBe('listener'); - expect(registry.loadBotConfigs()[0].vcMeetingAgent?.meetingConsumer?.defaultConsumerIds) - .toEqual(['minutes']); - expect(registry.loadBotConfigs()[0].vcMeetingAgent?.meetingConsumer?.defaultProfileBootstrap) - .toEqual(consumer.defaultProfileBootstrap); - - const second = await bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps); - expect(second).toEqual({ ok: true, seeded: false, reason: 'already_initialized' }); - expect(readConfig()).toEqual(raw); - }); - - it('detects generator-owned drift while ignoring operator-owned fields', async () => { - writeConfig([listener()]); - const { bootstrap } = await freshModules(); - expect(await bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps)) - .toMatchObject({ ok: true, seeded: true }); - const consumer = readConfig()[0].vcMeetingAgent.meetingConsumer; - - const operatorOnlyChange = structuredClone(consumer); - operatorOnlyChange.injectIntervalMs = 45_000; - expect(bootstrap.isVcMeetingDefaultConsumerProfileBootstrapIntact(operatorOnlyChange)).toBe(true); - - for (const mutate of [ - (value: any) => { value.defaultMode = 'listenOnly'; }, - (value: any) => { value.defaultConsumerIds = []; }, - (value: any) => { value.consumerProfiles[0].instructions += ' changed'; }, - (value: any) => { value.defaultProfileBootstrap.generatorVersion = 1; }, - ]) { - const changed = structuredClone(consumer); - mutate(changed); - expect(bootstrap.isVcMeetingDefaultConsumerProfileBootstrapIntact(changed)).toBe(false); - } - }); - - it('upgrades an untouched single-profile v1 seed in place and preserves its agent', async () => { - writeConfig([listener()]); - const { bootstrap } = await freshModules(); - const profileV1 = { +import { describe, expect, it } from 'vitest'; + +import { isLegacyVcMeetingDefaultConsumerSeedCandidate } from '../src/services/vc-meeting-consumer-profile-bootstrap.js'; + +/** + * 「启动时给本 bot 播种默认预设」已退役(改成 fleet 共享目录 + 读路径内置默认), + * 这里只剩对磁盘上历史播种残留的**识别**:识别成功才向操作者提议迁移,所以近似 + * 形状必须一律判否——那可能是操作者自己写的配置。 + */ +describe('legacy VC default consumer seed detection', () => { + const legacySeed = () => ({ + enabled: true, + injectIntervalMs: 30_000, + defaultMode: 'listenOnly', + consumerProfiles: [{ id: 'minutes', - agentAppId: 'retained-agent', + agentAppId: 'any-agent', label: '会议纪要', role: 'minutes', instructions: '持续整理会议纪要,重点记录已确认的决策、待办事项(含负责人和截止时间)以及未解决风险;字幕修订时更新已有条目,不重复记录同一事项。', responseMode: 'silent', capabilities: ['meeting.read'], - }; - const consumerV1 = { - enabled: true, - defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - consumerProfiles: [profileV1], - defaultProfileBootstrap: { - generatorVersion: 1, - profileId: 'minutes', - configHash: bootstrap.computeVcMeetingDefaultConsumerProfileConfigHash({ - defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - profile: profileV1, - }), - }, - }; - writeConfig([listener(consumerV1)]); - - expect(await bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps)) - .toEqual({ ok: true, seeded: true, agentAppId: 'retained-agent' }); - const upgraded = readConfig()[0].vcMeetingAgent.meetingConsumer; - expect(upgraded.defaultProfileBootstrap.generatorVersion).toBe(2); - expect(upgraded.consumerProfiles).toEqual([expect.objectContaining({ - id: 'minutes', - agentAppId: 'retained-agent', - responseMode: 'listener_thread', - capabilities: ['listener.output.request', 'meeting.output.request', 'meeting.read'], - ownedSinks: ['meeting_text', 'meeting_voice'], - })]); - expect(bootstrap.isVcMeetingDefaultConsumerProfileBootstrapIntact(upgraded)).toBe(true); - const afterUpgrade = readConfig(); - expect(await bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps)) - .toEqual({ ok: true, seeded: false, reason: 'already_initialized' }); - expect(readConfig()).toEqual(afterUpgrade); + }], }); - it('does not auto-escalate a v1 marker with drift or extra operator profiles', async () => { - writeConfig([listener()]); - const { bootstrap } = await freshModules(); - const profileV1 = { - id: 'minutes', - agentAppId: 'listener', - label: '会议纪要', - role: 'minutes', - instructions: '持续整理会议纪要,重点记录已确认的决策、待办事项(含负责人和截止时间)以及未解决风险;字幕修订时更新已有条目,不重复记录同一事项。', - responseMode: 'silent', - capabilities: ['meeting.read'], - }; - const base = { - enabled: true, - defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - consumerProfiles: [profileV1], - defaultProfileBootstrap: { - generatorVersion: 1, - profileId: 'minutes', - configHash: bootstrap.computeVcMeetingDefaultConsumerProfileConfigHash({ - defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - profile: profileV1, - }), - }, - }; - const cases = [ - { ...structuredClone(base), consumerProfiles: [{ ...profileV1, instructions: `${profileV1.instructions} 用户修改` }] }, - { - ...structuredClone(base), - consumerProfiles: [profileV1, { - id: 'observer', - agentAppId: 'other-agent', - role: 'observer', - responseMode: 'silent', - capabilities: ['meeting.read'], - }], - }, - ]; - for (const consumer of cases) { - writeConfig([listener(consumer)]); - const before = readConfig(); - expect(await bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps)) - .toEqual({ ok: true, seeded: false, reason: 'already_initialized' }); - expect(readConfig()).toEqual(before); - } + it('recognizes the exact pre-provenance generated seed', () => { + expect(isLegacyVcMeetingDefaultConsumerSeedCandidate(legacySeed())).toBe(true); }); - it('strictly recognizes only the exact pre-provenance generated seed', async () => { - writeConfig([listener()]); - const { bootstrap } = await freshModules(); - const legacySeed = { - enabled: true, - injectIntervalMs: 30_000, - defaultMode: 'listenOnly', - consumerProfiles: [{ - id: 'minutes', - agentAppId: 'any-agent', - label: '会议纪要', - role: 'minutes', - instructions: '持续整理会议纪要,重点记录已确认的决策、待办事项(含负责人和截止时间)以及未解决风险;字幕修订时更新已有条目,不重复记录同一事项。', - responseMode: 'silent', - capabilities: ['meeting.read'], - }], - }; - expect(bootstrap.isLegacyVcMeetingDefaultConsumerSeedCandidate(legacySeed)).toBe(true); + it.each([ + ['non-object', 'nope'], + ['array', []], + ['null', null], + ])('rejects %s', (_name, value) => { + expect(isLegacyVcMeetingDefaultConsumerSeedCandidate(value)).toBe(false); + }); + it('rejects every near miss', () => { for (const mutate of [ (value: any) => { value.defaultMode = 'agents'; }, (value: any) => { value.defaultConsumerIds = undefined; }, (value: any) => { value.defaultProfileBootstrap = undefined; }, (value: any) => { value.defaultAgentAppId = 'legacy-agent'; }, + (value: any) => { value.defaultAgent = 'legacy-agent'; }, + (value: any) => { value.agentCandidates = ['legacy-agent']; }, + (value: any) => { value.agents = ['legacy-agent']; }, + (value: any) => { value.consumerProfiles = []; }, (value: any) => { value.consumerProfiles.push(structuredClone(value.consumerProfiles[0])); }, (value: any) => { value.consumerProfiles[0].agentAppId = ' '; }, + (value: any) => { value.consumerProfiles[0].id = 'notes'; }, (value: any) => { value.consumerProfiles[0].label = '自定义纪要'; }, + (value: any) => { value.consumerProfiles[0].role = 'assistant'; }, + (value: any) => { value.consumerProfiles[0].responseMode = 'listener_thread'; }, (value: any) => { value.consumerProfiles[0].instructions += ' changed'; }, (value: any) => { value.consumerProfiles[0].capabilities = ['meeting.read', 'listener.output.request']; }, (value: any) => { value.consumerProfiles[0].filter = { activityTypes: ['speech'] }; }, (value: any) => { value.consumerProfiles[0].ownedSinks = ['meeting.text']; }, (value: any) => { value.consumerProfiles[0].extra = true; }, ]) { - const nearMiss = structuredClone(legacySeed); + const nearMiss = legacySeed(); mutate(nearMiss); - expect(bootstrap.isLegacyVcMeetingDefaultConsumerSeedCandidate(nearMiss)).toBe(false); + expect(isLegacyVcMeetingDefaultConsumerSeedCandidate(nearMiss)).toBe(false); } }); - - it('falls back to the lexical external agent when listener self is ineligible', async () => { - writeConfig([ - bot('listener', { - cliId: 'unknown', - vcMeetingAgent: { - enabled: true, - larkCliProfile: 'listener', - meetingConsumer: { enabled: true }, - }, - }), - bot('agent_z'), - bot('agent_a'), - ]); - const { bootstrap } = await freshModules(); - expect(await bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps)) - .toEqual({ ok: true, seeded: true, agentAppId: 'agent_a' }); - }); - - it('never resurrects an explicit empty catalog', async () => { - writeConfig([listener({ enabled: true, consumerProfiles: [], defaultMode: 'listenOnly' })]); - const before = readConfig(); - const { bootstrap } = await freshModules(); - expect(await bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps)) - .toEqual({ ok: true, seeded: false, reason: 'already_initialized' }); - expect(readConfig()).toEqual(before); - }); - - it.each([ - ['defaultAgentAppId', { defaultAgentAppId: 'agent_old' }], - ['defaultAgent alias', { defaultAgent: 'agent_old' }], - ['agentCandidates', { agentCandidates: ['agent_old'] }], - ['agents alias', { agents: ['agent_old'] }], - ['agent mode', { defaultMode: 'agent' }], - ['explicit listen-only mode', { defaultMode: 'listenOnly' }], - ['profile ids without a catalog', { defaultConsumerIds: [] }], - ])('leaves legacy/partial policy %s untouched', async (_name, policy) => { - writeConfig([listener({ enabled: true, ...policy }), bot('agent_old')]); - const before = readConfig(); - const { bootstrap } = await freshModules(); - expect(await bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps)) - .toEqual({ ok: true, seeded: false, reason: 'legacy_config' }); - expect(readConfig()).toEqual(before); - }); - - it('requires both listener and consumer enabled, and does not write without an eligible agent', async () => { - for (const entries of [ - [bot('listener', { vcMeetingAgent: { enabled: false, meetingConsumer: { enabled: true } } })], - [listener({ enabled: false })], - [bot('listener', { - cliId: 'unknown', - workingDir: undefined, - vcMeetingAgent: { enabled: true, meetingConsumer: { enabled: true } }, - })], - ]) { - writeConfig(entries); - const before = readConfig(); - const { bootstrap } = await freshModules(); - const result = await bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps); - expect(result).toMatchObject({ ok: true, seeded: false }); - expect(readConfig()).toEqual(before); - } - }); - - it('does not seed a receiver that is ineligible (e.g. requested sandbox is undeliverable)', async () => { - writeConfig([listener()]); - const before = readConfig(); - const { bootstrap } = await freshModules(); - const result = await bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', { - ...structuralDeps, - managedSideEffectEligible: () => false, - sandboxIsolated: () => false, - }); - expect(result).toEqual({ ok: true, seeded: false, reason: 'no_eligible_agent' }); - expect(readConfig()).toEqual(before); - }); - - it('serializes concurrent bootstraps so exactly one write wins', async () => { - writeConfig([listener(), bot('agent')]); - const { bootstrap } = await freshModules(); - const results = await Promise.all([ - bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps), - bootstrap.bootstrapVcMeetingDefaultConsumerProfile('listener', structuralDeps), - ]); - expect(results.filter(result => result.ok && result.seeded)).toHaveLength(1); - expect(readConfig()[0].vcMeetingAgent.meetingConsumer.consumerProfiles).toHaveLength(1); - }); }); diff --git a/test/vc-meeting-consumer-profile-store.test.ts b/test/vc-meeting-consumer-profile-store.test.ts deleted file mode 100644 index 6ab9f6c91..000000000 --- a/test/vc-meeting-consumer-profile-store.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('@larksuiteoapi/node-sdk', () => ({ - Client: class FakeClient {}, -})); - -async function freshModules() { - vi.resetModules(); - const registry = await import('../src/bot-registry.js'); - const store = await import('../src/services/vc-meeting-consumer-profile-store.js'); - registry.loadBotConfigs(); - return { registry, store }; -} - -describe('vc meeting consumer profile store', () => { - let configPath: string; - - beforeEach(() => { - const dir = mkdtempSync(join(tmpdir(), 'botmux-vc-profile-store-')); - configPath = join(dir, 'bots.json'); - process.env.BOTS_CONFIG = configPath; - }); - - afterEach(() => { - delete process.env.BOTS_CONFIG; - }); - - function writeConfig(entry: Record = {}): void { - writeFileSync(configPath, JSON.stringify([{ - larkAppId: 'cli_listener', - larkAppSecret: 'keep-secret', - cliId: 'claude-code', - workingDir: '/tmp', - marker: { keep: true }, - vcMeetingAgent: { - enabled: true, - larkCliProfile: 'listener-profile', - meetingConsumer: { - enabled: true, - injectIntervalMs: 30_000, - }, - }, - ...entry, - }], null, 2), 'utf8'); - } - - function readRaw(): any { - return JSON.parse(readFileSync(configPath, 'utf8'))[0]; - } - - function minutesProfile(instructions = '只记录明确决策。') { - return { - id: 'minutes', - agentAppId: 'cli_agent', - label: '会议纪要', - role: 'minutes', - instructions, - responseMode: 'listener_thread' as const, - capabilities: ['meeting.read', 'listener.output.request'], - }; - } - - it('reads a never-initialized config separately from an explicit empty catalog', async () => { - writeConfig(); - const { store } = await freshModules(); - const snapshot = await store.readVcMeetingConsumerProfiles('cli_listener'); - expect(snapshot).toMatchObject({ - listenerBotAppId: 'cli_listener', - catalogState: 'uninitialized', - defaultMode: 'listenOnly', - defaultConsumerIds: [], - profiles: [], - }); - expect(snapshot?.revision).toMatch(/^sha256:[0-9a-f]{64}$/u); - }); - - it('treats an explicitly persisted listen-only mode as operator-owned state', async () => { - writeConfig({ - vcMeetingAgent: { - enabled: true, - meetingConsumer: { - enabled: true, - defaultMode: 'listenOnly', - }, - }, - }); - const { store } = await freshModules(); - expect(await store.readVcMeetingConsumerProfiles('cli_listener')).toMatchObject({ - catalogState: 'legacy_or_partial', - defaultMode: 'listenOnly', - defaultConsumerIds: [], - profiles: [], - }); - }); - - it('atomically enters profile mode while preserving secrets and unrelated VC fields', async () => { - writeConfig({ - vcMeetingAgent: { - enabled: true, - larkCliProfile: 'listener-profile', - meetingConsumer: { - enabled: true, - injectIntervalMs: 30_000, - defaultMode: 'agent', - defaultAgentAppId: 'cli_old', - agentCandidates: ['cli_old'], - }, - }, - }); - const { store } = await freshModules(); - const before = await store.readVcMeetingConsumerProfiles('cli_listener'); - expect(before).toBeDefined(); - const result = await store.updateVcMeetingConsumerProfiles('cli_listener', { - expectedRevision: before!.revision, - defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - profiles: [minutesProfile(' 第一行\r\n第二行 ')], - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.snapshot.profiles[0]?.instructions).toBe('第一行\n第二行'); - expect(result.snapshot.defaultMode).toBe('agents'); - const raw = readRaw(); - expect(raw.larkAppSecret).toBe('keep-secret'); - expect(raw.marker).toEqual({ keep: true }); - expect(raw.vcMeetingAgent.larkCliProfile).toBe('listener-profile'); - expect(raw.vcMeetingAgent.meetingConsumer.injectIntervalMs).toBe(30_000); - expect(raw.vcMeetingAgent.meetingConsumer.defaultAgentAppId).toBeUndefined(); - expect(raw.vcMeetingAgent.meetingConsumer.agentCandidates).toBeUndefined(); - }); - - it('detects a hand edit through the canonical expected revision', async () => { - writeConfig(); - const { store } = await freshModules(); - const before = await store.readVcMeetingConsumerProfiles('cli_listener'); - const raw = readRaw(); - raw.vcMeetingAgent.meetingConsumer.injectIntervalMs = 45_000; - writeFileSync(configPath, JSON.stringify([raw], null, 2), 'utf8'); - const result = await store.updateVcMeetingConsumerProfiles('cli_listener', { - expectedRevision: before!.revision, - defaultMode: 'listenOnly', - defaultConsumerIds: [], - profiles: [minutesProfile()], - }); - expect(result).toEqual({ ok: false, reason: 'config_conflict' }); - }); - - it('detects a concurrent raw-only legacy opt-out that normalization would drop', async () => { - writeConfig(); - const { store } = await freshModules(); - const before = await store.readVcMeetingConsumerProfiles('cli_listener'); - const raw = readRaw(); - raw.vcMeetingAgent.meetingConsumer.agentCandidates = []; - writeFileSync(configPath, JSON.stringify([raw], null, 2), 'utf8'); - - const result = await store.updateVcMeetingConsumerProfiles('cli_listener', { - expectedRevision: before!.revision, - defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - profiles: [minutesProfile()], - }); - expect(result).toEqual({ ok: false, reason: 'config_conflict' }); - expect(readRaw().vcMeetingAgent.meetingConsumer.agentCandidates).toEqual([]); - }); - - it('clears generator provenance on an explicit profile save', async () => { - const profile = minutesProfile(); - writeConfig({ - vcMeetingAgent: { - enabled: true, - meetingConsumer: { - enabled: true, - defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - consumerProfiles: [profile], - defaultProfileBootstrap: { - generatorVersion: 1, - profileId: 'minutes', - configHash: `sha256:${'a'.repeat(64)}`, - }, - }, - }, - }); - const { store } = await freshModules(); - const before = await store.readVcMeetingConsumerProfiles('cli_listener'); - expect(before?.defaultProfileBootstrap?.profileId).toBe('minutes'); - const result = await store.updateVcMeetingConsumerProfiles('cli_listener', { - expectedRevision: before!.revision, - defaultMode: 'agents', - defaultConsumerIds: ['minutes'], - profiles: [profile], - }); - expect(result.ok).toBe(true); - expect(readRaw().vcMeetingAgent.meetingConsumer.defaultProfileBootstrap).toBeUndefined(); - if (result.ok) expect(result.snapshot.defaultProfileBootstrap).toBeUndefined(); - }); - - it('offers an explicit default activation only for the exact legacy generated seed', async () => { - const legacyProfile = { - id: 'minutes', - agentAppId: 'cli_agent', - label: '会议纪要', - role: 'minutes', - instructions: '持续整理会议纪要,重点记录已确认的决策、待办事项(含负责人和截止时间)以及未解决风险;字幕修订时更新已有条目,不重复记录同一事项。', - responseMode: 'silent', - capabilities: ['meeting.read'], - }; - writeConfig({ - vcMeetingAgent: { - enabled: true, - meetingConsumer: { - enabled: true, - injectIntervalMs: 30_000, - defaultMode: 'listenOnly', - consumerProfiles: [legacyProfile], - }, - }, - }); - const { store } = await freshModules(); - expect((await store.readVcMeetingConsumerProfiles('cli_listener'))?.migrationOffer) - .toBe('enable_seeded_minutes_default'); - - const raw = readRaw(); - raw.vcMeetingAgent.meetingConsumer.consumerProfiles[0].label = '我的纪要'; - writeFileSync(configPath, JSON.stringify([raw], null, 2), 'utf8'); - expect((await store.readVcMeetingConsumerProfiles('cli_listener'))?.migrationOffer) - .toBeUndefined(); - }); - - it('returns a DTO field path for invalid instructions and does not write', async () => { - writeConfig(); - const { store } = await freshModules(); - const before = await store.readVcMeetingConsumerProfiles('cli_listener'); - const result = await store.updateVcMeetingConsumerProfiles('cli_listener', { - expectedRevision: before!.revision, - defaultMode: 'listenOnly', - defaultConsumerIds: [], - profiles: [minutesProfile('bad\u0000prompt')], - }); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.reason).toBe('validation_failed'); - expect(result.fieldErrors?.[0]?.path).toBe('profiles[0].instructions'); - expect(readRaw().vcMeetingAgent.meetingConsumer.consumerProfiles).toBeUndefined(); - }); - - it('validates conflicts in the default selection but permits alternatives in the catalog', async () => { - writeConfig(); - const { store } = await freshModules(); - const before = await store.readVcMeetingConsumerProfiles('cli_listener'); - const speakers = ['speaker-a', 'speaker-b'].map((id, index) => ({ - id, - agentAppId: `cli_speaker_${index}`, - role: id, - responseMode: 'silent' as const, - capabilities: ['meeting.read', 'meeting.output.request'], - ownedSinks: ['meeting_text' as const], - })); - const alternatives = await store.updateVcMeetingConsumerProfiles('cli_listener', { - expectedRevision: before!.revision, - defaultMode: 'listenOnly', - defaultConsumerIds: [], - profiles: speakers, - }); - expect(alternatives.ok).toBe(true); - if (!alternatives.ok) return; - const conflict = await store.updateVcMeetingConsumerProfiles('cli_listener', { - expectedRevision: alternatives.snapshot.revision, - defaultMode: 'agents', - defaultConsumerIds: ['speaker-a', 'speaker-b'], - profiles: speakers, - }); - expect(conflict.ok).toBe(false); - if (!conflict.ok) { - expect(conflict.reason).toBe('validation_failed'); - expect(conflict.fieldErrors?.[0]?.path).toBe('defaultConsumerIds'); - } - }); - - it('keeps an explicit empty catalog in listen-only mode', async () => { - writeConfig(); - const { store } = await freshModules(); - const before = await store.readVcMeetingConsumerProfiles('cli_listener'); - const result = await store.updateVcMeetingConsumerProfiles('cli_listener', { - expectedRevision: before!.revision, - defaultMode: 'listenOnly', - defaultConsumerIds: [], - profiles: [], - }); - expect(result.ok).toBe(true); - expect(readRaw().vcMeetingAgent.meetingConsumer.consumerProfiles).toEqual([]); - expect(readRaw().vcMeetingAgent.meetingConsumer.defaultMode).toBe('listenOnly'); - if (result.ok) expect(result.snapshot.catalogState).toBe('explicit_empty'); - }); - - it.each([ - ['empty legacy alias', { agentCandidates: [] }], - ['legacy default', { defaultAgentAppId: 'cli_agent' }], - ['legacy mode', { defaultMode: 'agent' }], - ['partial profile ids', { defaultConsumerIds: [] }], - ['partial agents mode', { defaultMode: 'agents' }], - ])('classifies %s from raw own-properties', async (_name, policy) => { - writeConfig({ - vcMeetingAgent: { - enabled: true, - meetingConsumer: { enabled: true, ...policy }, - }, - }); - const { store } = await freshModules(); - expect((await store.readVcMeetingConsumerProfiles('cli_listener'))?.catalogState) - .toBe('legacy_or_partial'); - }); - - it.each([ - { - name: 'unknown default id', - defaultConsumerIds: ['missing'], - profiles: [] as ReturnType[], - }, - { - name: 'duplicate default id', - defaultConsumerIds: ['minutes', 'minutes'], - profiles: [minutesProfile()], - }, - { - name: 'empty agents default', - defaultConsumerIds: [], - profiles: [minutesProfile()], - }, - ])('rejects $name instead of silently changing the submitted policy', async ({ defaultConsumerIds, profiles }) => { - writeConfig(); - const { store } = await freshModules(); - const before = await store.readVcMeetingConsumerProfiles('cli_listener'); - const result = await store.updateVcMeetingConsumerProfiles('cli_listener', { - expectedRevision: before!.revision, - defaultMode: 'agents', - defaultConsumerIds, - profiles, - }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe('validation_failed'); - expect(readRaw().vcMeetingAgent.meetingConsumer.consumerProfiles).toBeUndefined(); - }); -}); diff --git a/test/vc-meeting-daemon-session.test.ts b/test/vc-meeting-daemon-session.test.ts index ecdc843df..a783a6828 100644 --- a/test/vc-meeting-daemon-session.test.ts +++ b/test/vc-meeting-daemon-session.test.ts @@ -464,6 +464,9 @@ function registerListenerBotForRejoin(opts: { realtimeVoice?: boolean } = {}): v enabled: true, larkCliProfile: APP_ID, attentionTargetOpenId: TARGET_OPEN_ID, + // 这批用例考的是监听群成员进出的围栏(谁被移出、谁能重新入群),跟会中角色 + // 选择卡无关。显式关掉消费面,免得共享预设目录的卡片混进 sentMessages 的计数。 + meetingConsumer: { enabled: false }, ...(opts.realtimeVoice ? { realtimeVoice: { enabled: true } } : {}), }, }); @@ -912,6 +915,56 @@ describe('VC meeting daemon session lifecycle', () => { delete process.env.BOTMUX_TIME_SCALE; }); + describe('larkCliProfile 入会身份默认值(拉任意 bot 进会即可用)', () => { + // beforeEach 注册的 APP_ID bot 只有 { enabled: true },没有 larkCliProfile—— + // 正是 fleet 里 43/47 从没点过「配置权限」按钮的 bot 的形状。这批用例锁住: + // 读路径把入会身份默认成 bot 自己的 appId,于是入会门禁不再以 no_profile 拒绝。 + it('defaults larkCliProfile to the bot own appId when unset', () => { + const cfg = __vcMeetingAgentTest.effectiveConfig(APP_ID); + expect(cfg?.larkCliProfile).toBe(APP_ID); + }); + + it('never overrides an operator-set larkCliProfile', () => { + registerBot({ + larkAppId: 'cli_vc_explicit_profile', + larkAppSecret: 'secret', + cliId: 'claude-code', + vcMeetingAgent: { enabled: true, larkCliProfile: 'operator_chosen_profile' }, + }); + const cfg = __vcMeetingAgentTest.effectiveConfig('cli_vc_explicit_profile'); + expect(cfg?.larkCliProfile).toBe('operator_chosen_profile'); + }); + + it('applies the default even when the bot has no vcMeetingAgent block at all', () => { + registerBot({ + larkAppId: 'cli_vc_bare', + larkAppSecret: 'secret', + cliId: 'claude-code', + }); + const cfg = __vcMeetingAgentTest.effectiveConfig('cli_vc_bare'); + // vcMeetingAgentConfigActive 对 Feishu-connected bot 返回 {},默认补上入会身份。 + expect(cfg?.larkCliProfile).toBe('cli_vc_bare'); + }); + + it('does not fabricate a profile for apiOnly bots (they never join)', () => { + registerBot({ + larkAppId: 'cli_vc_api_only', + larkAppSecret: '', + cliId: 'claude-code', + apiOnly: true, + vcMeetingAgent: { enabled: true }, + }); + // apiOnly → vcMeetingAgentConfigActive 返回 undefined,读路径整段跳过。 + expect(__vcMeetingAgentTest.effectiveConfig('cli_vc_api_only')).toBeUndefined(); + }); + + it('does not mutate the stored bot config (pure read path)', () => { + __vcMeetingAgentTest.effectiveConfig(APP_ID); + // 存储侧仍是原始形状——默认值只活在读路径派生对象里,绝不回写 bots.json。 + expect(getBot(APP_ID).config.vcMeetingAgent?.larkCliProfile).toBeUndefined(); + }); + }); + it('ingests activity into the meeting session without dispatching workflow', async () => { await __vcMeetingAgentTest.handlePush({ larkAppId: APP_ID, @@ -1469,15 +1522,21 @@ describe('VC meeting daemon session lifecycle', () => { expect(__vcMeetingAgentTest.hasSession(APP_ID, 'm_tracked_global_off')).toBe(false); }); - it('global listener bot selection blocks new meetings for non-selected apps', async () => { + it('a legacy global-listener pin is ignored: every enabled bot handles its own invite', async () => { registerBot({ larkAppId: OTHER_APP_ID, larkAppSecret: 'secret', cliId: 'claude-code', vcMeetingAgent: { enabled: true }, }); + // Even with a legacy pin pointing at APP_ID, OTHER_APP_ID must start its own + // meeting now — the pin is retired and no longer routes/blocks. __vcMeetingAgentTest.setGlobalVcMeetingListenerBotAppIdForTest(APP_ID); + // 入会身份现在默认成 bot 自己的 appId(读路径),所以邀请会真的触发一次 + // joinMeetingAsBot。让 mock 回同一个 meeting.id,避免走「join 回来的 id 与邀请 + // 不一致 → 重映射 session key」的分支——那条分支是另一回事,本用例只考路由。 + joinMeetingIdOverrides.push('m_global_listener_other'); await __vcMeetingAgentTest.handlePush({ larkAppId: OTHER_APP_ID, kind: 'meeting_invited', @@ -1486,7 +1545,7 @@ describe('VC meeting daemon session lifecycle', () => { meeting: { id: 'm_global_listener_other', meetingNo: '123456789', topic: 'Wrong listener' }, raw: { event: { meeting: { id: 'm_global_listener_other', meeting_no: '123456789' } } }, }); - expect(__vcMeetingAgentTest.hasSession(OTHER_APP_ID, 'm_global_listener_other')).toBe(false); + expect(__vcMeetingAgentTest.hasSession(OTHER_APP_ID, 'm_global_listener_other')).toBe(true); await __vcMeetingAgentTest.handlePush({ larkAppId: APP_ID, @@ -1511,7 +1570,7 @@ describe('VC meeting daemon session lifecycle', () => { expect(__vcMeetingAgentTest.hasSession(APP_ID, 'm_global_listener_selected')).toBe(true); }); - it('global listener bot selection does not interrupt already tracked meetings on old apps', async () => { + it('a legacy global-listener pin set mid-stream never interrupts any bot\'s tracked meeting', async () => { await __vcMeetingAgentTest.handlePush({ larkAppId: APP_ID, kind: 'meeting_activity', @@ -2030,10 +2089,17 @@ describe('VC meeting daemon session lifecycle', () => { expect(joinCalls).toEqual([{ meetingNumber: '123456789', profile: APP_ID }]); expect(groupCreateCalls).toHaveLength(1); expect(groupCreateCalls[0].userOpenIds).toEqual([TARGET_OPEN_ID]); - expect(sentMessages.some(msg => msg.msgType === 'interactive')).toBe(false); - expect(sentMessages).toHaveLength(1); - expect(sentMessages[0].receiveId).toBe('oc_listener_1'); - expect(JSON.parse(sentMessages[0].content).text).toContain('会议监听已开始'); + const textMessages = sentMessages.filter(msg => msg.msgType !== 'interactive'); + expect(textMessages).toHaveLength(1); + expect(textMessages[0].receiveId).toBe('oc_listener_1'); + expect(JSON.parse(textMessages[0].content).text).toContain('会议监听已开始'); + + // 这个 bot 从没配过 meetingConsumer——过去意味着「进会只能干听,一个角色都选 + // 不到」。现在它继承 fleet 共享预设目录,被拉进会就直接拿到角色选择卡。 + const consumerCards = sentMessages.filter(msg => msg.msgType === 'interactive'); + expect(consumerCards).toHaveLength(1); + const labels = interactiveCardLabels(JSON.parse(consumerCards[0].content)); + expect(labels.some(label => label?.includes('会议纪要'))).toBe(true); await __vcMeetingAgentTest.handlePush({ larkAppId: APP_ID, @@ -2046,7 +2112,7 @@ describe('VC meeting daemon session lifecycle', () => { expect(joinCalls).toHaveLength(1); expect(groupCreateCalls).toHaveLength(1); - expect(sentMessages).toHaveLength(1); + expect(sentMessages).toHaveLength(2); }); it('skips join and DMs the owner when the lark-cli join profile cannot be provisioned', async () => { @@ -2201,7 +2267,9 @@ describe('VC meeting daemon session lifecycle', () => { expect(sentMessages.filter(msg => msg.msgType === 'interactive')).toHaveLength(1); expect(joinCalls).toHaveLength(1); expect(groupCreateCalls).toHaveLength(1); - expect(realtimeVoiceEvents).toContain('stop:listener-removed'); + // 实时语音改成按需建连后,入会不再急着开语音会话——本 bot 全程没发言,所以 + // 移除时没有语音会话可停。语音生命周期由下面专门的按需建连用例覆盖。 + expect(realtimeVoiceEvents).not.toContain('start'); expect(runtimeStoreRecords.find(record => record.meeting.id === 'm_invite')) .toEqual(expect.objectContaining({ listenerPresenceStale: true, @@ -2582,6 +2650,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', consumerProfiles: [{ id: 'self-speaker', @@ -2646,8 +2715,11 @@ describe('VC meeting daemon session lifecycle', () => { memberEpoch: member!.memberEpoch, }, }); - expect(receiver?.activeKey).toContain(`vc-receiver:${member!.receiverSessionId}`); - expect(receiver?.activeKey).not.toBe(receiver?.ordinaryChatKey); + // Plan B: the meeting agent is an ordinary chat-scope session, so its + // active-map slot IS the normal (chatId, appId) key. Plain IM to this chat + // and meeting transcripts therefore fold into the SAME session. + expect(receiver?.activeKey).toBe(receiver?.ordinaryChatKey); + expect(receiver?.activeKey).not.toContain('vc-receiver:'); const origin = { listenerAppId: member!.listenerAppId, @@ -2823,6 +2895,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', selectionTimeoutMs: 20_000, consumerProfiles: [ @@ -5552,7 +5625,10 @@ describe('VC meeting daemon session lifecycle', () => { }); }); - it('uses all locally registered bots as meeting consumer candidates when no allowlist is configured', async () => { + it('offers shared role presets instead of other bots when the bot has no profiles of its own', async () => { + // 另一个在线 bot:老模型会把它列进「选 agent」下拉,选中后再 addBotToChat 把它 + // 拉进监听群——「拉 A 进会却把 B 拉进群」。现在会中只选角色,执行方恒为收到 + // 这场会议事件的 bot 自己,别的 bot 不该出现在卡片上。 registerConsumerAgentBot(); registerBot({ larkAppId: APP_ID, @@ -5582,10 +5658,9 @@ describe('VC meeting daemon session lifecycle', () => { const card = JSON.parse(sentMessages.find(msg => msg.msgType === 'interactive')!.content); const labels = interactiveCardLabels(card); - expect(labels).toContain('Meeting Bot (claude-code)'); - expect(labels).toContain('Agent Claude (claude-code)'); - expect(labels).not.toContain(APP_ID); - expect(labels).not.toContain(AGENT_APP_ID); + expect(labels.some(label => label?.includes('会议纪要'))).toBe(true); + expect(labels).not.toContain('Agent Claude (claude-code)'); + expect(JSON.stringify(card)).not.toContain(AGENT_APP_ID); }); it('adds the selected meeting consumer agent to the listener chat and pins chat-scope', async () => { @@ -8044,9 +8119,12 @@ describe('VC meeting daemon session lifecycle', () => { AGENT_APP_ID, 'oc_listener_1', ); - expect(routed.result).toEqual({ - anchorOverride: `vc-receiver:${member.receiverSessionId}`, - }); + // Plan B: the meeting agent is an ordinary chat-scope session at the normal + // (chatId, appId) slot, so the natural chat anchor already resolves it — the + // hook no longer returns a `vc-receiver:` anchor override. It still stamps + // vcMeetingImTurnOrigin (below) so the @mention follow-up carries meeting + // delivery identity, and it does not itself trigger a session turn. + expect(routed.result).toBeUndefined(); expect(routed.ctx).toMatchObject({ vcMeetingContextMayLag: false, vcMeetingContextLifecycle: 'sealed', @@ -8187,6 +8265,73 @@ describe('VC meeting daemon session lifecycle', () => { expect(triggerSessionCalls[0].req.envelope.rawText).toContain('@用户 这个问题需要马上看一下'); }); + it('treats any instruction-source speech as a fast signal (no question mark required)', async () => { + // The authorizing user speaking plain statements used to wait out the + // regular flush tick (only "@" chats or instruction-source questions were + // fast) — the operator's own words now inject immediately. + registerConsumerAgentBot(); + registerBot({ + larkAppId: APP_ID, + larkAppSecret: 'secret', + cliId: 'claude-code', + vcMeetingAgent: { + enabled: true, + larkCliProfile: APP_ID, + attentionTargetOpenId: TARGET_OPEN_ID, + meetingConsumer: { + enabled: true, + defaultMode: 'listenOnly', + minBatchChars: 1_000, + minBatchItems: 10, + maxInjectIntervalMs: 60_000, + agentCandidates: [ + { larkAppId: AGENT_APP_ID, label: 'Claude Loopy' }, + ], + }, + }, + }); + + await __vcMeetingAgentTest.handlePush({ + larkAppId: APP_ID, + kind: 'meeting_invited', + eventType: 'vc.bot.meeting_invited_v1', + eventId: 'evt_invite_operator_fast', + meeting: { id: 'm_operator_fast', meetingNo: '555555561', topic: 'Operator speech fast signal' }, + raw: { event: { meeting: { id: 'm_operator_fast', meeting_no: '555555561' } } }, + }); + await selectConsumerAgentViaCard('Claude Loopy'); + + await __vcMeetingAgentTest.handlePush({ + larkAppId: APP_ID, + kind: 'meeting_activity', + eventType: 'vc.bot.meeting_activity_v1', + eventId: 'evt_operator_fast_activity', + meeting: { id: 'm_joined_555555561', meetingNo: '555555561', topic: 'Operator speech fast signal' }, + raw: { + event: { + meeting_actitivty_items: [ + { + activity_event_type: 'chat_received', + meeting: { id: 'm_joined_555555561', meeting_no: '555555561', topic: 'Operator speech fast signal' }, + chat_received_items: [ + { + message_id: 'msg_operator_fast_1', + sender: { open_id: TARGET_OPEN_ID, user_name: 'Operator' }, + text: '先把上周的进展同步一下', + }, + ], + }, + ], + }, + }, + }); + + await new Promise(resolve => setTimeout(resolve, 2)); + + expect(triggerSessionCalls).toHaveLength(1); + expect(triggerSessionCalls[0].req.envelope.rawText).toContain('先把上周的进展同步一下'); + }); + it('temporarily authorizes in-meeting instruction sources without expanding output approval', async () => { registerConsumerAgentBot(); registerBot({ @@ -8199,6 +8344,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', minBatchChars: 1_000, minBatchItems: 10, @@ -9003,6 +9149,7 @@ describe('VC meeting daemon session lifecycle', () => { enabled: true, }, meetingConsumer: { + voiceOutputPolicy: 'approval', // approval workflow under test; default is now 'allow' enabled: true, defaultMode: 'listenOnly', agentCandidates: [ @@ -9079,7 +9226,9 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, realtimeVoice: { enabled: true }, meetingConsumer: { + voiceOutputPolicy: 'approval', // voice half of this test exercises review; default is now 'allow' enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', agentCandidates: [{ larkAppId: AGENT_APP_ID, label: 'Claude Loopy' }], }, @@ -9206,6 +9355,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', agentCandidates: [{ larkAppId: AGENT_APP_ID, label: 'Claude Loopy' }], }, @@ -9359,6 +9509,7 @@ describe('VC meeting daemon session lifecycle', () => { enabled: true, }, meetingConsumer: { + voiceOutputPolicy: 'approval', // approval workflow under test; default is now 'allow' enabled: true, defaultMode: 'listenOnly', agentCandidates: [ @@ -9423,6 +9574,7 @@ describe('VC meeting daemon session lifecycle', () => { enabled: true, }, meetingConsumer: { + voiceOutputPolicy: 'approval', // approval workflow under test; default is now 'allow' enabled: true, defaultMode: 'listenOnly', agentCandidates: [ @@ -9486,6 +9638,7 @@ describe('VC meeting daemon session lifecycle', () => { enabled: true, }, meetingConsumer: { + voiceOutputPolicy: 'approval', // approval workflow under test; default is now 'allow' enabled: true, defaultMode: 'listenOnly', agentCandidates: [ @@ -9571,6 +9724,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', agentCandidates: [ { larkAppId: AGENT_APP_ID, label: 'Claude Loopy' }, @@ -9645,6 +9799,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', agentCandidates: [ { larkAppId: AGENT_APP_ID, label: 'Claude Loopy' }, @@ -9703,6 +9858,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', agentCandidates: [ { larkAppId: AGENT_APP_ID, label: 'Claude Loopy' }, @@ -9770,6 +9926,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', agentCandidates: [ { larkAppId: AGENT_APP_ID, label: 'Claude Loopy' }, @@ -9842,6 +9999,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', agentCandidates: [ { larkAppId: AGENT_APP_ID, label: 'Claude Loopy' }, @@ -9894,6 +10052,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', agentCandidates: [ { larkAppId: AGENT_APP_ID, label: 'Claude Loopy' }, @@ -9946,6 +10105,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', agentCandidates: [ { larkAppId: AGENT_APP_ID, label: 'Claude Loopy' }, @@ -9999,6 +10159,7 @@ describe('VC meeting daemon session lifecycle', () => { enabled: true, }, meetingConsumer: { + voiceOutputPolicy: 'approval', // approval workflow under test; default is now 'allow' enabled: true, defaultMode: 'listenOnly', agentCandidates: [ @@ -10066,6 +10227,7 @@ describe('VC meeting daemon session lifecycle', () => { attentionTargetOpenId: TARGET_OPEN_ID, meetingConsumer: { enabled: true, + textOutputPolicy: 'approval', // these tests exercise the approval workflow; default is now 'allow' defaultMode: 'listenOnly', agentCandidates: [ { larkAppId: AGENT_APP_ID, label: 'Claude Loopy' }, diff --git a/test/vc-meeting-delivery-store.test.ts b/test/vc-meeting-delivery-store.test.ts index bee53605f..105179aa2 100644 --- a/test/vc-meeting-delivery-store.test.ts +++ b/test/vc-meeting-delivery-store.test.ts @@ -14,6 +14,7 @@ import { getVcMeetingDeliveryReceipt, getVcMeetingMemberProjection, getVcMeetingReceiverStream, + latestVcMeetingDeliveryForSession, listActiveVcMeetingDeliveriesForSession, listVcMeetingActiveProjectionsForReceiverSession, listVcMeetingMemberProjections, @@ -1093,6 +1094,47 @@ describe('vc meeting delivery store', () => { }); }); + describe('latestVcMeetingDeliveryForSession', () => { + beforeEach(() => { + applyVcMeetingMemberProjection(dir, projection()); + applyVcMeetingMemberProjection(dir, projection({ meetingId: 'meeting-2' })); + acceptVcMeetingDelivery(dir, delivery()); + acceptVcMeetingDelivery(dir, delivery({ meetingId: 'meeting-2', deliveryKey: 'dk-2' })); + }); + + it('returns a just-COMPLETED receipt (the idle-gap case listActive skips)', () => { + // The whole point: after the delivery turn goes idle the receipt is + // `completed` (terminal). listActive returns [] for it; the in-meeting + // output CLI needs THIS so it can still supply an authorizable origin. + const rk = { ...KEY, meetingId: 'meeting-2', deliveryKey: 'dk-2' }; + markVcMeetingDeliveryDispatched(dir, rk, { receiverBootId: 'boot-rx-1', workerGeneration: 3 }); + completeVcMeetingDelivery(dir, rk, { workerGeneration: 3, dispatchAttempt: 1 }); + + const latest = latestVcMeetingDeliveryForSession(dir, 'sess-1'); + expect(latest).toMatchObject({ + receiverSessionId: 'sess-1', + receipt: { deliveryKey: 'dk-2', status: 'completed', dispatchAttempt: 1 }, + }); + // listActive skips it (terminal) — this is exactly the gap this helper fills. + expect(listActiveVcMeetingDeliveriesForSession(dir, 'sess-1').map(e => e.receipt.deliveryKey)) + .toEqual(['dk-1']); + }); + + it('prefers the most-recent authorizable receipt and never returns failed/abandoned', () => { + // dk-1 dispatched (authorizable). dk-2 abandoned (not). Latest authorizable + // is dk-1. + markVcMeetingDeliveryDispatched(dir, { ...KEY, deliveryKey: 'dk-1' }, { receiverBootId: 'boot-rx-1', workerGeneration: 1 }); + abandonVcMeetingDeliveryStream(dir, { listenerAppId: LISTENER, meetingId: 'meeting-2', memberId: MEMBER, memberEpoch: 1 }); + const latest = latestVcMeetingDeliveryForSession(dir, 'sess-1'); + expect(latest?.receipt.deliveryKey).toBe('dk-1'); + expect(latest?.receipt.status).toBe('dispatched'); + }); + + it('returns undefined for an unknown session', () => { + expect(latestVcMeetingDeliveryForSession(dir, 'no-such-session')).toBeUndefined(); + }); + }); + // ─── 按 receiverSessionId 反查当前有效 projection(botmux send 拒发面)──── describe('listVcMeetingActiveProjectionsForReceiverSession', () => { diff --git a/test/vc-meeting-receiver-recovery-lifecycle.test.ts b/test/vc-meeting-receiver-recovery-lifecycle.test.ts index 642cc1731..e05c05d45 100644 --- a/test/vc-meeting-receiver-recovery-lifecycle.test.ts +++ b/test/vc-meeting-receiver-recovery-lifecycle.test.ts @@ -157,4 +157,29 @@ describe('VC meeting receiver boot-recovery lifecycle', () => { expect(catchBlock).toContain('escalateVcMeetingBootRecovery(recoveryKey, ref.receiverSessionId)'); expect(catchBlock).not.toContain('clearVcMeetingReceiverRecoveryPending(recoveryKey)'); }); + + it('Plan B: boot-recovery eligibility is ledger-derived (ambiguous delivery receipts), not a session-marker scan', () => { + // Under Plan B the meeting agent is an ordinary chat-scope session, so the + // vcMeetingReceiver marker can no longer be the recovery predicate — a plain + // user turn carries the marker too. The recovery loop must iterate the + // reconciled delivery ledger (ambiguousOnBoot), which only ever contains + // stale durable delivery receipts, so a session that only ever ran plain user + // turns (no ledger receipt) is never fenced at boot. + const bootIdx = daemonSource.indexOf('const ambiguousOnBoot = reconcileVcMeetingDeliveriesOnBoot('); + expect(bootIdx).toBeGreaterThanOrEqual(0); + const loopIdx = daemonSource.indexOf('for (const ref of ambiguousOnBoot) {'); + expect(loopIdx).toBeGreaterThan(bootIdx); + // The loop resolves the live session by sessionId (works regardless of the + // now-ordinary activeSessions key) and verifies the meeting identity — it does + // NOT scan sessions by the vcMeetingReceiver marker to decide eligibility. + const loopBlock = daemonSource.slice(loopIdx, loopIdx + 400); + expect(loopBlock).toContain('findActiveBySessionId(ref.receiverSessionId)'); + }); + + it('Plan B: a session with no durable delivery receipt is not boot-recovery blocked', () => { + // A plain user turn's session never gets a delivery ledger entry, so the boot + // gate must not block it. No recovery started for this sessionId → not blocked. + recovery.finishScheduling(); + expect(recovery.isBlocked(delivery('sess_plain_user_turn', 'member_none'))).toBe(false); + }); }); diff --git a/test/vc-meeting-send-policy.test.ts b/test/vc-meeting-send-policy.test.ts index 9d96b68c7..c81bde1d0 100644 --- a/test/vc-meeting-send-policy.test.ts +++ b/test/vc-meeting-send-policy.test.ts @@ -93,6 +93,46 @@ describe('evaluateVcMeetingManagedSend', () => { })).toMatchObject({ ok: false, errorCode: 'silent_delivery' }); }); + it('Plan B: authorizes a silent delivery for the IN-MEETING output channel (silent only gates listener auto-post)', () => { + // responseMode:silent means "do not auto-post to the listener group", NOT + // "cannot speak in the meeting". The in-meeting managed-output channel + // (request-output → hub managed-action) is authorized by capability + + // textOutputPolicy/voiceOutputPolicy at the hub, so forInMeetingOutput skips + // the silent veto while still proving receipt identity/liveness. A "会议主持" + // that is silent in the group must still be able to speak in the meeting. + seed('silent'); + expect(evaluateVcMeetingManagedSend(dir, { + receiverSessionId: 'receiver-session', receiverSession: true, + turnId: 'delivery-key', dispatchAttempt: 1, + forInMeetingOutput: true, + })).toMatchObject({ ok: true, kind: 'listener_thread' }); + // Same delivery, default (listener auto-post) path stays suppressed. + expect(evaluateVcMeetingManagedSend(dir, { + receiverSessionId: 'receiver-session', receiverSession: true, + turnId: 'delivery-key', dispatchAttempt: 1, + })).toMatchObject({ ok: false, errorCode: 'silent_delivery' }); + }); + + it('Plan B: forInMeetingOutput still fails closed on a missing/mismatched receipt (no silent bypass of identity checks)', () => { + // The flag skips ONLY the silent veto — every identity/liveness check + // (receipt exists for this session, attempt matches, active projection, + // dispatched/completed) still applies, so it can never authorize output the + // receipt itself wouldn't. + seed('silent'); + // Wrong dispatchAttempt → origin_mismatch even with forInMeetingOutput. + expect(evaluateVcMeetingManagedSend(dir, { + receiverSessionId: 'receiver-session', receiverSession: true, + turnId: 'delivery-key', dispatchAttempt: 99, + forInMeetingOutput: true, + })).toMatchObject({ ok: false, errorCode: 'origin_mismatch' }); + // Unknown turnId → receipt_not_found even with forInMeetingOutput. + expect(evaluateVcMeetingManagedSend(dir, { + receiverSessionId: 'receiver-session', receiverSession: true, + turnId: 'no-such-delivery', dispatchAttempt: 1, + forInMeetingOutput: true, + })).toMatchObject({ ok: false, errorCode: 'receipt_not_found' }); + }); + it('allows the exact durable origin for listener_thread mode', () => { seed('listener_thread'); expect(evaluateVcMeetingManagedSend(dir, { diff --git a/test/vc-meeting-shared-consumer-catalog.test.ts b/test/vc-meeting-shared-consumer-catalog.test.ts new file mode 100644 index 000000000..004cb232f --- /dev/null +++ b/test/vc-meeting-shared-consumer-catalog.test.ts @@ -0,0 +1,552 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@larksuiteoapi/node-sdk', () => ({ Client: class FakeClient {} })); + +/** + * 每个用例都从干净模块图起:全局配置有读缓存,而共享目录的读路径三态判定 + * (uninitialized / explicit_empty / profiles)正是靠原始 JSON 区分的。 + */ +async function freshModules() { + vi.resetModules(); + return { + globalConfig: await import('../src/global-config.js'), + store: await import('../src/services/vc-meeting-shared-consumer-catalog-store.js'), + bind: await import('../src/services/vc-meeting-shared-consumer-catalog.js'), + }; +} + +type Raw = Record; + +describe('vc meeting shared consumer catalog store', () => { + let home: string; + + beforeEach(async () => { + home = mkdtempSync(join(tmpdir(), 'botmux-vc-shared-catalog-')); + vi.stubEnv('HOME', home); + const { globalConfig } = await freshModules(); + mkdirSync(dirname(globalConfig.globalConfigPath()), { recursive: true }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + }); + + async function writeGlobal(vcMeetingAgent: Raw): Promise { + const { globalConfig } = await freshModules(); + writeFileSync(globalConfig.globalConfigPath(), JSON.stringify({ vcMeetingAgent }, null, 2), 'utf8'); + } + + async function readGlobalRaw(): Promise { + const { globalConfig } = await freshModules(); + return JSON.parse(readFileSync(globalConfig.globalConfigPath(), 'utf8')) as Raw; + } + + function sharedProfile(over: Raw = {}): Raw { + return { + id: 'minutes', + label: '会议纪要', + role: 'minutes', + responseMode: 'silent', + capabilities: ['meeting.read'], + ...over, + }; + } + + it('falls back to the built-in default when the catalog was never configured', async () => { + const { store } = await freshModules(); + const snap = store.readVcMeetingSharedConsumerCatalogSnapshot(); + + // 「装好就能用」:43/47 个 bot 从来没有 vcMeetingAgent 配置,读路径内置默认 + // 才能让它们被拉进会时就有角色可跑,且不需要 daemon 启动时抢着写配置。 + expect(snap.catalogState).toBe('uninitialized'); + expect(snap.profiles.map(profile => profile.id)).toEqual(['minutes']); + expect(snap.defaultMode).toBe('agents'); + expect(snap.defaultConsumerIds).toEqual(['minutes']); + // 目录条目**没有**执行方字段:执行方是被拉进会的那个 bot 自己。 + expect(Object.keys(snap.profiles[0])).not.toContain('agentAppId'); + // 读一次不产生任何写盘。 + expect(() => readFileSync(store.vcMeetingSharedConsumerCatalogConfigPath(), 'utf8')).toThrow(); + }); + + it('keeps an explicitly emptied catalog empty instead of resurrecting the built-in default', async () => { + await writeGlobal({ consumerCatalog: { profiles: [], defaultMode: 'listenOnly', defaultConsumerIds: [] } }); + const { store } = await freshModules(); + const snap = store.readVcMeetingSharedConsumerCatalogSnapshot(); + + expect(snap.catalogState).toBe('explicit_empty'); + expect(snap.profiles).toEqual([]); + expect(snap.defaultMode).toBe('listenOnly'); + }); + + it('hashes the raw catalog so a hand edit that normalization drops still bumps the revision', async () => { + await writeGlobal({ consumerCatalog: { profiles: [sharedProfile()], defaultMode: 'listenOnly', defaultConsumerIds: [] } }); + const before = (await freshModules()).store.readVcMeetingSharedConsumerCatalogSnapshot(); + + // defaultConsumerIds 指向不存在的预设:读路径会把它过滤掉,归一化结果与上面 + // 完全相同——若 revision 哈希的是归一化结果,这次手改就会悄悄绕过乐观并发。 + await writeGlobal({ + consumerCatalog: { profiles: [sharedProfile()], defaultMode: 'listenOnly', defaultConsumerIds: ['ghost'] }, + }); + const after = (await freshModules()).store.readVcMeetingSharedConsumerCatalogSnapshot(); + + expect(after.defaultConsumerIds).toEqual([]); + expect(after.profiles).toEqual(before.profiles); + expect(after.revision).not.toBe(before.revision); + }); + + it('drops only the unparsable entry on read, keeping the rest of the catalog alive', async () => { + await writeGlobal({ + consumerCatalog: { + profiles: [sharedProfile(), { id: 'broken', role: 'x', responseMode: 'nonsense', capabilities: [] }], + defaultMode: 'agents', + defaultConsumerIds: ['minutes'], + }, + }); + const { store } = await freshModules(); + const snap = store.readVcMeetingSharedConsumerCatalogSnapshot(); + + // 目录是**全局**的:手改配置写坏一条,不能让全 fleet 的 bot 一起变成干听。 + // 读路径逐条 forgiving(坏条目消失),保存路径才是权威校验。 + expect(snap.profiles.map(profile => profile.id)).toEqual(['minutes']); + expect(snap.defaultConsumerIds).toEqual(['minutes']); + }); + + it('rejects a stale expectedRevision without writing', async () => { + await writeGlobal({ consumerCatalog: { profiles: [sharedProfile()], defaultMode: 'listenOnly', defaultConsumerIds: [] } }); + const { store } = await freshModules(); + const result = store.updateVcMeetingSharedConsumerCatalog({ + expectedRevision: 'sha256:stale', + defaultMode: 'listenOnly', + defaultConsumerIds: [], + profiles: [], + }); + + expect(result).toEqual({ ok: false, reason: 'config_conflict' }); + expect(((await readGlobalRaw()).vcMeetingAgent as Raw).consumerCatalog).toMatchObject({ + profiles: [expect.objectContaining({ id: 'minutes' })], + }); + }); + + it('refuses two default roles in plain language (one bot runs one role)', async () => { + const { store } = await freshModules(); + const current = store.readVcMeetingSharedConsumerCatalogSnapshot(); + const result = store.updateVcMeetingSharedConsumerCatalog({ + expectedRevision: current.revision, + defaultMode: 'agents', + defaultConsumerIds: ['minutes', 'facilitator'], + profiles: [sharedProfile(), sharedProfile({ id: 'facilitator', role: 'facilitator', label: '主持' })] as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('validation_failed'); + expect(result.fieldErrors?.[0].path).toBe('defaultConsumerIds'); + expect(result.fieldErrors?.[0].message).toContain('只能有一个默认角色'); + }); + + it('reports a DTO-shaped field path for a malformed profile without writing', async () => { + const { store } = await freshModules(); + const current = store.readVcMeetingSharedConsumerCatalogSnapshot(); + const result = store.updateVcMeetingSharedConsumerCatalog({ + expectedRevision: current.revision, + defaultMode: 'listenOnly', + defaultConsumerIds: [], + profiles: [sharedProfile({ responseMode: 'nonsense' })] as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('validation_failed'); + expect(result.fieldErrors?.[0].path).toMatch(/^profiles/u); + }); + + it('rejects "agents" with nothing selected instead of silently downgrading it', async () => { + const { store } = await freshModules(); + const current = store.readVcMeetingSharedConsumerCatalogSnapshot(); + const result = store.updateVcMeetingSharedConsumerCatalog({ + expectedRevision: current.revision, + defaultMode: 'agents', + defaultConsumerIds: [], + profiles: [sharedProfile()] as never, + }); + + // UI 侧删预设/清空 id 时会同步把 defaultMode 降回 listenOnly,所以这个组合 + // 只会来自直接打 API;宁可 422 也不猜操作者想要哪个角色。 + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe('validation_failed'); + // 错误必须落在 UI 真的会渲染的锚点上(这两个锚点在默认角色区相邻渲染), + // 否则用户只看到「保存失败」却找不到哪一项有问题。 + expect(['defaultMode', 'defaultConsumerIds']).toContain(result.fieldErrors?.[0].path); + }); + + it('never persists an executor field, even if the client injects one', async () => { + const { store } = await freshModules(); + const current = store.readVcMeetingSharedConsumerCatalogSnapshot(); + const result = store.updateVcMeetingSharedConsumerCatalog({ + expectedRevision: current.revision, + defaultMode: 'agents', + defaultConsumerIds: ['minutes'], + profiles: [sharedProfile({ agentAppId: 'cli_someone_else' })] as never, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.snapshot.revision).not.toBe(current.revision); + expect(Object.keys(result.snapshot.profiles[0])).not.toContain('agentAppId'); + + const persisted = ((await readGlobalRaw()).vcMeetingAgent as Raw).consumerCatalog as Raw; + // 回归:注入的 agentAppId 绝不能落盘——它正是「拉 A 进会却把 B 拉进群」的载体。 + expect(JSON.stringify(persisted)).not.toContain('agentAppId'); + expect(JSON.stringify(persisted)).not.toContain('cli_someone_else'); + }); + + it('preserves unrelated global settings across a catalog write', async () => { + await writeGlobal({ enabled: true, listenerBotAppId: 'cli_legacy_pin' }); + const { globalConfig } = await freshModules(); + globalConfig.mergeGlobalConfig({ lang: 'en' }); + + const { store } = await freshModules(); + const current = store.readVcMeetingSharedConsumerCatalogSnapshot(); + const result = store.updateVcMeetingSharedConsumerCatalog({ + expectedRevision: current.revision, + defaultMode: 'agents', + defaultConsumerIds: ['minutes'], + profiles: [sharedProfile()] as never, + }); + + expect(result.ok).toBe(true); + const raw = await readGlobalRaw(); + expect(raw.lang).toBe('en'); + expect((raw.vcMeetingAgent as Raw).enabled).toBe(true); + expect((raw.vcMeetingAgent as Raw).listenerBotAppId).toBe('cli_legacy_pin'); + }); +}); + +describe('bindVcMeetingConsumerCatalogToBot', () => { + const SELF = 'cli_self'; + const OTHER = 'cli_other'; + + function catalog(over: Record = {}) { + return { + profiles: [{ + id: 'minutes', + label: '会议纪要', + role: 'minutes', + responseMode: 'silent', + capabilities: ['meeting.read'], + }], + defaultMode: 'agents', + defaultConsumerIds: ['minutes'], + ...over, + } as never; + } + + function seededProfile(agentAppId: string) { + return { + id: 'minutes', + agentAppId, + label: '会议纪要', + role: 'minutes', + responseMode: 'silent', + capabilities: ['meeting.read'], + instructions: '持续整理会议纪要,重点记录已确认的决策、待办事项(含负责人和截止时间)以及未解决风险;字幕修订时更新已有条目,不重复记录同一事项。', + }; + } + + async function bindModule() { + const { bind } = await freshModules(); + bind.__resetSharedConsumerCatalogWarnState(); + return bind; + } + + it('gives a bot with no VC consumer config the shared catalog, bound to itself', async () => { + const bind = await bindModule(); + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, { enabled: true } as never, { + readCatalog: () => catalog(), + }); + + expect(out.meetingConsumer?.enabled).toBe(true); + expect(out.meetingConsumer?.consumerProfiles?.map(profile => profile.agentAppId)).toEqual([SELF]); + expect(out.meetingConsumer?.defaultMode).toBe('agents'); + expect(out.meetingConsumer?.defaultConsumerIds).toEqual(['minutes']); + }); + + it('uses the built-in catalog when nothing is configured globally either', async () => { + const bind = await bindModule(); + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, { enabled: true } as never, { + readCatalog: () => undefined, + }); + + // 「没配过」和「显式清空」是两回事:前者落内置默认,后者是持久的「都不要」。 + expect(out.meetingConsumer?.consumerProfiles?.map(profile => profile.id)).toEqual(['minutes']); + expect(out.meetingConsumer?.consumerProfiles?.[0].agentAppId).toBe(SELF); + }); + + it('honours an explicitly emptied shared catalog', async () => { + const bind = await bindModule(); + const cfg = { enabled: true } as never; + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, cfg, { + readCatalog: () => catalog({ profiles: [], defaultMode: 'listenOnly', defaultConsumerIds: [] }), + }); + + expect(out).toBe(cfg); + }); + + it('never rewrites operator-written per-bot profiles, including a deliberate cross-bot executor', async () => { + const bind = await bindModule(); + // 多 agent 分工是真实用法:操作者可以有意把某个角色指给另一个 bot 跑。 + const cfg = { + enabled: true, + meetingConsumer: { + enabled: true, + consumerProfiles: [{ + id: 'review', agentAppId: OTHER, role: 'review', + responseMode: 'silent', capabilities: ['meeting.read'], + }], + defaultMode: 'agents', + defaultConsumerIds: ['review'], + }, + } as never; + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, cfg, { readCatalog: () => catalog() }); + + expect(out).toBe(cfg); + expect(out.meetingConsumer?.consumerProfiles?.[0].agentAppId).toBe(OTHER); + }); + + it('treats an explicit empty per-bot list as "this bot takes no role"', async () => { + const bind = await bindModule(); + const cfg = { enabled: true, meetingConsumer: { enabled: true, consumerProfiles: [] } } as never; + + expect(bind.bindVcMeetingConsumerCatalogToBot(SELF, cfg, { readCatalog: () => catalog() })).toBe(cfg); + }); + + it('leaves legacy executor policy (agentCandidates / defaultAgentAppId) alone', async () => { + const bind = await bindModule(); + for (const legacy of [ + { agentCandidates: [OTHER] }, + { defaultAgentAppId: OTHER }, + { defaultAgent: OTHER }, + { agents: [OTHER] }, + ]) { + const cfg = { enabled: true, meetingConsumer: { enabled: true, ...legacy } } as never; + expect(bind.bindVcMeetingConsumerCatalogToBot(SELF, cfg, { readCatalog: () => catalog() })).toBe(cfg); + } + }); + + it('lets a v2 seeded block fall back to the shared catalog and re-binds the executor to self', async () => { + // 这就是用户看到的「拉 A 进会,却把 B 拉进监听群」:播种把另一个 bot 的 appId + // 焊进了预设。读路径忽略播种残留即修好,磁盘残留原样留着(零迁移写盘)。 + const bind = await bindModule(); + const cfg = { + enabled: true, + meetingConsumer: { + enabled: true, + consumerProfiles: [seededProfile(OTHER)], + defaultMode: 'agents', + defaultConsumerIds: ['minutes'], + defaultProfileBootstrap: { generatorVersion: 2, profileId: 'minutes', configHash: 'sha256:whatever' }, + }, + } as never; + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, cfg, { + readCatalog: () => catalog({ + profiles: [{ + id: 'minutes', label: '共享纪要', role: 'minutes', + responseMode: 'listener_thread', capabilities: ['meeting.read', 'listener.output.request'], + }], + }), + }); + + expect(out.meetingConsumer?.consumerProfiles?.map(profile => profile.agentAppId)).toEqual([SELF]); + expect(out.meetingConsumer?.consumerProfiles?.[0].label).toBe('共享纪要'); + expect(cfg.meetingConsumer.consumerProfiles[0].agentAppId).toBe(OTHER); // 入参不被改 + }); + + // 回归(PR#916 codex阻断②):seeded 块被判要忽略后,若共享目录不可用而 fallback + // 原样 `return cfg`,焊死的 foreign appId 会从 fallback 复活——正是 PR 要消灭的 + // 「拉 A 拉 B」。fallback 对 seeded 必须剥成仅监听。 + const seededV2Cfg = () => ({ + enabled: true, + meetingConsumer: { + enabled: true, + consumerProfiles: [seededProfile(OTHER)], + defaultMode: 'agents', + defaultConsumerIds: ['minutes'], + defaultProfileBootstrap: { generatorVersion: 2, profileId: 'minutes', configHash: 'sha256:whatever' }, + }, + }) as never; + + it('strips a seeded block (not resurrect foreign appId) when the catalog is explicitly emptied', async () => { + const bind = await bindModule(); + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, seededV2Cfg(), { + readCatalog: () => catalog({ profiles: [], defaultMode: 'listenOnly', defaultConsumerIds: [] }), + }); + // 目录显式清空 → 该 bot 不跑角色;但绝不能把 OTHER 的预设留在配置里。 + expect(out.meetingConsumer?.consumerProfiles).toEqual([]); + expect(out.meetingConsumer?.defaultMode).toBe('listenOnly'); + }); + + it('strips a seeded block when every catalog entry is invalid (foreign appId never survives fallback)', async () => { + const bind = await bindModule(); + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, seededV2Cfg(), { + // 整份目录都是坏条目 → bound.length===0 fallback;seeded 仍须剥离。 + readCatalog: () => catalog({ profiles: [{ id: 'bad', role: 'minutes', responseMode: 'nope' }] }), + }); + expect(out.meetingConsumer?.consumerProfiles).toEqual([]); + expect(out.meetingConsumer?.defaultMode).toBe('listenOnly'); + const appIds = (out.meetingConsumer?.consumerProfiles ?? []).map(p => p.agentAppId); + expect(appIds).not.toContain(OTHER); + }); + + it('lets a pre-provenance (v1) seeded block fall back too, but keeps a near miss', async () => { + const bind = await bindModule(); + const v1 = (profile: Record) => ({ + enabled: true, + meetingConsumer: { enabled: true, defaultMode: 'listenOnly', consumerProfiles: [profile] }, + }) as never; + + const seeded = bind.bindVcMeetingConsumerCatalogToBot(SELF, v1(seededProfile(OTHER)), { + readCatalog: () => catalog(), + }); + expect(seeded.meetingConsumer?.consumerProfiles?.map(profile => profile.agentAppId)).toEqual([SELF]); + + // 差一个字段就当操作者内容:v1 只能按形状识别,宁可保守。 + const nearMiss = v1({ ...seededProfile(OTHER), instructions: '我自己改过的指令' }); + expect(bind.bindVcMeetingConsumerCatalogToBot(SELF, nearMiss, { readCatalog: () => catalog() })) + .toBe(nearMiss); + }); + + it('selects at most one default role even if the catalog lists several', async () => { + const bind = await bindModule(); + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, { enabled: true } as never, { + readCatalog: () => catalog({ + profiles: [ + { id: 'minutes', role: 'minutes', responseMode: 'silent', capabilities: ['meeting.read'] }, + { id: 'facilitator', role: 'facilitator', responseMode: 'silent', capabilities: ['meeting.read'] }, + ], + defaultConsumerIds: ['minutes', 'facilitator'], + }), + }); + + // 绑定后两条预设的 agentAppId 都是这个 bot,resolver 会拒绝同时选中两条。 + expect(out.meetingConsumer?.consumerProfiles).toHaveLength(2); + expect(out.meetingConsumer?.defaultConsumerIds).toEqual(['minutes']); + }); + + it('honors a per-bot catalogDefaultConsumerId over the catalog global default', async () => { + const bind = await bindModule(); + const out = bind.bindVcMeetingConsumerCatalogToBot( + SELF, + { enabled: true, meetingConsumer: { catalogDefaultConsumerId: 'facilitator' } } as never, + { + readCatalog: () => catalog({ + profiles: [ + { id: 'minutes', role: 'minutes', responseMode: 'silent', capabilities: ['meeting.read'] }, + { id: 'facilitator', role: 'facilitator', responseMode: 'silent', capabilities: ['meeting.read'] }, + ], + defaultConsumerIds: ['minutes'], + }), + }, + ); + // per-bot 挑了 facilitator,覆盖全局默认 minutes。 + expect(out.meetingConsumer?.defaultMode).toBe('agents'); + expect(out.meetingConsumer?.defaultConsumerIds).toEqual(['facilitator']); + }); + + it('per-bot default forces agents mode even when the catalog global is listen-only', async () => { + const bind = await bindModule(); + const out = bind.bindVcMeetingConsumerCatalogToBot( + SELF, + { enabled: true, meetingConsumer: { catalogDefaultConsumerId: 'minutes' } } as never, + { readCatalog: () => catalog({ defaultMode: 'listenOnly', defaultConsumerIds: [] }) }, + ); + expect(out.meetingConsumer?.defaultMode).toBe('agents'); + expect(out.meetingConsumer?.defaultConsumerIds).toEqual(['minutes']); + }); + + it('ignores a per-bot default that is not in the catalog, falling back to the global default', async () => { + const bind = await bindModule(); + const out = bind.bindVcMeetingConsumerCatalogToBot( + SELF, + { enabled: true, meetingConsumer: { catalogDefaultConsumerId: 'ghost' } } as never, + { readCatalog: () => catalog() }, + ); + // 'ghost' 不在目录里 → 当没配、回落全局默认 minutes。 + expect(out.meetingConsumer?.defaultConsumerIds).toEqual(['minutes']); + }); + + it('drops default ids that no longer exist, falling back to listen-only', async () => { + const bind = await bindModule(); + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, { enabled: true } as never, { + readCatalog: () => catalog({ defaultConsumerIds: ['ghost'] }), + }); + + expect(out.meetingConsumer?.consumerProfiles).toHaveLength(1); + expect(out.meetingConsumer?.defaultMode).toBe('listenOnly'); + }); + + it('keeps listen-only catalogs listen-only (roles are still switchable in-meeting)', async () => { + const bind = await bindModule(); + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, { enabled: true } as never, { + readCatalog: () => catalog({ defaultMode: 'listenOnly', defaultConsumerIds: [] }), + }); + + expect(out.meetingConsumer?.defaultMode).toBe('listenOnly'); + expect(out.meetingConsumer?.consumerProfiles).toHaveLength(1); + }); + + it('drops only the broken catalog entry, still binding the good ones', async () => { + const bind = await bindModule(); + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, { enabled: true } as never, { + readCatalog: () => catalog({ + profiles: [ + { id: 'bad', role: 'bad', responseMode: 'nonsense', capabilities: [] }, + { id: 'minutes', role: 'minutes', responseMode: 'silent', capabilities: ['meeting.read'] }, + ], + }), + }); + + // daemon 侧丢弃的条目必须与 Dashboard 读到的一致,否则页面上列着的角色 + // 一个都跑不起来。 + expect(out.meetingConsumer?.consumerProfiles?.map(profile => profile.id)).toEqual(['minutes']); + expect(out.meetingConsumer?.defaultConsumerIds).toEqual(['minutes']); + }); + + it('ignores a fully broken shared catalog instead of taking the whole fleet down', async () => { + const bind = await bindModule(); + const cfg = { enabled: true } as never; + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, cfg, { + readCatalog: () => catalog({ + profiles: [{ id: 'bad', role: 'bad', responseMode: 'nonsense', capabilities: [] }], + }), + }); + + // 一份坏的全局目录只丢弃自己,不能让每个 bot 的会议能力一起挂。 + expect(out).toBe(cfg); + }); + + it('returns the config untouched for a bot with no appId', async () => { + const bind = await bindModule(); + const cfg = { enabled: true } as never; + + expect(bind.bindVcMeetingConsumerCatalogToBot('', cfg, { readCatalog: () => catalog() })).toBe(cfg); + }); + + it('preserves unrelated meetingConsumer fields while binding', async () => { + const bind = await bindModule(); + const cfg = { + enabled: true, + meetingConsumer: { enabled: false, injectIntervalMs: 30_000 }, + } as never; + const out = bind.bindVcMeetingConsumerCatalogToBot(SELF, cfg, { readCatalog: () => catalog() }); + + expect(out.meetingConsumer?.injectIntervalMs).toBe(30_000); + // 显式关掉会议消费的 bot 不会被共享目录偷偷打开。 + expect(out.meetingConsumer?.enabled).toBe(false); + expect(out.meetingConsumer?.consumerProfiles?.[0].agentAppId).toBe(SELF); + }); +}); diff --git a/test/write-input.test.ts b/test/write-input.test.ts index 9745eed55..41d33e317 100644 --- a/test/write-input.test.ts +++ b/test/write-input.test.ts @@ -57,6 +57,8 @@ import { createMiraAdapter } from '../src/adapters/cli/mira.js'; import { createPiAdapter } from '../src/adapters/cli/pi.js'; import { createKimiAdapter } from '../src/adapters/cli/kimi.js'; import { createGrokAdapter } from '../src/adapters/cli/grok.js'; +import { createRelayAdapter } from '../src/adapters/cli/relay.js'; +import { createSeedAdapter } from '../src/adapters/cli/seed.js'; import { createKiroCliAdapter } from '../src/adapters/cli/kiro-cli.js'; import type { CliAdapter, PtyHandle } from '../src/adapters/cli/types.js'; import { appendFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; @@ -640,6 +642,11 @@ describe('reliableTurnTerminal capability', () => { expect(createCodexAdapter('/bin/codex').reliableTurnTerminal).toBe(true); expect(createTraexAdapter('/bin/traex').reliableTurnTerminal).toBe(true); expect(createGrokAdapter('/bin/grok').reliableTurnTerminal).toBe(true); + // Relay/Seed 是 Claude Code 的 fork,落盘 JSONL 与 Claude Code 同构(同样的 + // `stop_reason:end_turn` + system 回合标记),兑现同一份 turn-terminal 契约, + // 故与 claude-code 一样 opt-in——让 relay/seed 系 bot 能当会议 agent。 + expect(createRelayAdapter('/bin/relay').reliableTurnTerminal).toBe(true); + expect(createSeedAdapter('/bin/seed').reliableTurnTerminal).toBe(true); expect(createCocoAdapter('/bin/coco').reliableTurnTerminal).toBeUndefined(); // Pi supports type-ahead but NOT reliableTurnTerminal: it holds no session // fd (append short open/close) and a custom-terminate turn has no on-disk