From 7aa5182f3d60f627e30cbf73c6c07a75e254d829 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 19:27:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=9F=E8=83=BD=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E8=BF=9B=E6=99=BA=E8=83=BD=E4=BD=93=E6=8F=90=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E8=A1=A5=E5=AD=97=E5=B9=95=E6=8C=89=E5=8A=9F=E8=83=BD=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E7=BA=A6=E5=AE=9A=E6=94=B9=E4=B8=BA=E6=A0=B7=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 外围功能模块(补字幕、原站视图、桌面模式、网页搜索、MCP、定时任务)声明 manifest,与 FEATURES 一一对应; 系统提示「能力地图」末尾列出外围功能(没有开关,能力只列本次可用的) - 补字幕:订阅 organization.finished(认出值得补字幕的整理,自动补字幕还没做,不建任务), 写入字幕后在同一事务发 subtitle.written;原有行为不变 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/server/src/modules/browser/index.ts | 2 + apps/server/src/modules/desktop/index.ts | 2 + apps/server/src/modules/mcp/index.ts | 2 + apps/server/src/modules/schedules/index.ts | 2 + apps/server/src/modules/subtitles/events.ts | 28 ++++++ apps/server/src/modules/subtitles/index.ts | 17 +++- apps/server/src/modules/subtitles/service.ts | 37 +++++--- apps/server/src/modules/websearch/index.ts | 2 + apps/server/test/modules.test.ts | 94 ++++++++++++++++++- .../test/subtitles/subtitle-upload.test.ts | 13 +++ docs/plan/subtitles.md | 2 + packages/agent/src/prompt/index.ts | 1 + .../agent/src/prompt/layers/capabilities.ts | 17 +++- packages/agent/test/features-prompt.test.ts | 35 +++++++ 14 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/modules/subtitles/events.ts create mode 100644 packages/agent/test/features-prompt.test.ts diff --git a/apps/server/src/modules/browser/index.ts b/apps/server/src/modules/browser/index.ts index 56cb43a1..bf33ad40 100644 --- a/apps/server/src/modules/browser/index.ts +++ b/apps/server/src/modules/browser/index.ts @@ -1,3 +1,4 @@ +import { featureManifest } from "@umt/contracts"; import { sourcesIdentityModule } from "../sources-identity/index.ts"; import type { ServerContext, ServerModule } from "../types.ts"; import { registerBrowserCapabilities } from "./capabilities.ts"; @@ -36,6 +37,7 @@ export function createBrowserModule(opts: BrowserModuleOptions = {}): ServerModu }; return { name: "browser", + manifest: featureManifest("browser"), service, registerRoutes(app, ctx) { registerBrowserRoutes(app, () => service(ctx)); diff --git a/apps/server/src/modules/desktop/index.ts b/apps/server/src/modules/desktop/index.ts index 2dbe9fd1..8f1256c1 100644 --- a/apps/server/src/modules/desktop/index.ts +++ b/apps/server/src/modules/desktop/index.ts @@ -1,3 +1,4 @@ +import { featureManifest } from "@umt/contracts"; import { browserServiceFor } from "../browser/index.ts"; import type { ServerContext, ServerModule } from "../types.ts"; import { dbSessionChecks, desktopUpstream, registerDesktopRoutes } from "./desktop.ts"; @@ -11,6 +12,7 @@ export function createDesktopModule(): ServerModule { let closeAll: (() => void) | null = null; return { name: "desktop", + manifest: featureManifest("desktop"), registerRoutes(app, ctx: ServerContext) { const r = registerDesktopRoutes(app, { upstream: desktopUpstream({ diff --git a/apps/server/src/modules/mcp/index.ts b/apps/server/src/modules/mcp/index.ts index 0b81571d..0236cbe7 100644 --- a/apps/server/src/modules/mcp/index.ts +++ b/apps/server/src/modules/mcp/index.ts @@ -1,3 +1,4 @@ +import { featureManifest } from "@umt/contracts"; import denoJson from "../../../deno.json" with { type: "json" }; import type { ServerModule } from "../types.ts"; import { createMcpConfigStore } from "./config-store.ts"; @@ -13,6 +14,7 @@ export function createMcpModule(opts: { version?: string } = {}): ServerModule & const table = new McpSessionTable(); return { name: "mcp", + manifest: featureManifest("mcp"), sessions: table, registerRoutes(app, ctx) { registerMcpRoutes(app, ctx, { diff --git a/apps/server/src/modules/schedules/index.ts b/apps/server/src/modules/schedules/index.ts index 89ecf795..d21dffcf 100644 --- a/apps/server/src/modules/schedules/index.ts +++ b/apps/server/src/modules/schedules/index.ts @@ -1,3 +1,4 @@ +import { featureManifest } from "@umt/contracts"; import type { ServerContext, ServerModule } from "../types.ts"; import { builtinAgentCheckDefs } from "./agent-checks.ts"; import { registerScheduleCapabilities } from "./capabilities.ts"; @@ -26,6 +27,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server let scheduler: Scheduler | null = null; return { name: "schedules", + manifest: featureManifest("schedules"), scheduler: () => scheduler, registerRoutes(app, ctx) { registerScheduleRoutes(app, ctx, () => scheduleRegistry(ctx)); diff --git a/apps/server/src/modules/subtitles/events.ts b/apps/server/src/modules/subtitles/events.ts new file mode 100644 index 00000000..806dbaa8 --- /dev/null +++ b/apps/server/src/modules/subtitles/events.ts @@ -0,0 +1,28 @@ +import { type EventPayload, parseEventPayload } from "@umt/contracts"; +import type { OutboxRouteFn } from "@umt/tasks"; +import type { ServerContext } from "../types.ts"; + +// 补字幕订阅的生命周期事件(ADR-006 功能模块样板)。 +// 「入库后自动补字幕」还没做(docs/plan/subtitles.md):这里只认出值得补字幕的整理,记一条调试日志,不建任务。 +// 以后做自动补字幕时把 null 换成 { task: "...", input } 即可,订阅关系不用再改。 + +const IMPORT_ACTIONS = new Set(["copy", "move", "in_place"]); + +/** 整理结束事件里值得补字幕的:成功或部分成功、入库类动作(复制 / 移动 / 原地入库)、认到了作品 */ +export function subtitleCandidate( + p: EventPayload<"organization.finished">, +): { recordId: string; mediaEntityId: string } | null { + if (p.outcome === "failed" || !IMPORT_ACTIONS.has(p.action) || !p.mediaEntityId) return null; + return { recordId: p.recordId, mediaEntityId: p.mediaEntityId }; +} + +export function subtitlesOutboxRoutes(ctx: Pick): Record { + return { + "organization.finished": (ev) => { + const p = parseEventPayload("organization.finished", ev.payload); + const c = p ? subtitleCandidate(p) : null; + if (c) ctx.logger.debug(c, "补字幕:整理已完成,自动补字幕还没做,跳过"); + return null; + }, + }; +} diff --git a/apps/server/src/modules/subtitles/index.ts b/apps/server/src/modules/subtitles/index.ts index 3a4cd20c..fac59325 100644 --- a/apps/server/src/modules/subtitles/index.ts +++ b/apps/server/src/modules/subtitles/index.ts @@ -1,9 +1,16 @@ -import { Api, type SubtitleApplyRequest, type SubtitleFetchRequest, type SubtitleSource } from "@umt/contracts"; +import { + Api, + featureManifest, + type SubtitleApplyRequest, + type SubtitleFetchRequest, + type SubtitleSource, +} from "@umt/contracts"; import { ApiException } from "../../http/errors.ts"; import { route } from "../agent/http.ts"; import { human } from "../browser/routes.ts"; import { sourcesIdentityModule } from "../sources-identity/index.ts"; import type { ServerModule } from "../types.ts"; +import { subtitlesOutboxRoutes } from "./events.ts"; import { downloadFromSession, downloadOnline, @@ -13,10 +20,15 @@ import { } from "./online/index.ts"; import { applySubtitles, parseSubtitles, subtitleTargets, subtitleWrites, undoSubtitleWrite } from "./service.ts"; -/** 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销 */ +/** + * 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销。 + * 功能模块样板(ADR-006):manifest 进功能清单(给智能体),订阅 organization.finished(events.ts), + * 写入后发 subtitle.written;前端经插槽挂在作品页操作区与整理记录行菜单(apps/web/app/features/subtitles.tsx)。 + */ export function createSubtitlesModule(): ServerModule { return { name: "subtitles", + manifest: featureManifest("subtitles"), registerRoutes(app, ctx) { const tag = { tag: "subtitles" }; const rt = () => sourcesIdentityModule.runtime(ctx); @@ -69,6 +81,7 @@ export function createSubtitlesModule(): ServerModule { route(app, Api.subtitles.undo, ({ params }) => undoSubtitleWrite(ctx, params.id), tag); }, registerTasks() {}, + outboxRoutes: (ctx) => subtitlesOutboxRoutes(ctx), }; } diff --git a/apps/server/src/modules/subtitles/service.ts b/apps/server/src/modules/subtitles/service.ts index a430e786..5f963f91 100644 --- a/apps/server/src/modules/subtitles/service.ts +++ b/apps/server/src/modules/subtitles/service.ts @@ -1,15 +1,16 @@ import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import type { - SubtitleApplyRequest, - SubtitleApplyResult, - SubtitleLanguage, - SubtitleParsedItem, - SubtitleParseResponse, - SubtitleSource, - SubtitleTarget, - SubtitleTargetsResponse, - SubtitleWriteView, +import { + lifecycleKey, + type SubtitleApplyRequest, + type SubtitleApplyResult, + type SubtitleLanguage, + type SubtitleParsedItem, + type SubtitleParseResponse, + type SubtitleSource, + type SubtitleTarget, + type SubtitleTargetsResponse, + type SubtitleWriteView, } from "@umt/contracts"; import type { SubtitleWriteTable } from "@umt/db"; import { @@ -259,7 +260,7 @@ export async function applySubtitles( }) .execute(); } - return await trx + const w = await trx .insertInto("subtitle_write") .values({ id, @@ -277,6 +278,20 @@ export async function applySubtitles( }) .returningAll() .executeTakeFirstOrThrow(); + // 生命周期广播(ADR-006):字幕已写入 + await ctx.outbox.emit( + trx, + "subtitle.written", + { + writeId: w.id, + mediaEntityId: w.media_entity_id, + videoLocationId: t.locationId, + language: w.language as SubtitleLanguage, + source: w.source as SubtitleSource, + }, + { idempotencyKey: lifecycleKey("subtitle.written", w.id) }, + ); + return w; }); written.push(writeView(row)); } catch (e) { diff --git a/apps/server/src/modules/websearch/index.ts b/apps/server/src/modules/websearch/index.ts index 20b47c50..b06f27ed 100644 --- a/apps/server/src/modules/websearch/index.ts +++ b/apps/server/src/modules/websearch/index.ts @@ -1,3 +1,4 @@ +import { featureManifest } from "@umt/contracts"; import type { ServerContext, ServerModule } from "../types.ts"; import { registerWebSearchCapabilities } from "./capabilities.ts"; import { registerWebSearchRoutes } from "./routes.ts"; @@ -24,6 +25,7 @@ export function createWebSearchModule(opts: WebSearchModuleOptions = {}): Server }; return { name: "websearch", + manifest: featureManifest("websearch"), service, registerRoutes(app, ctx) { registerWebSearchRoutes(app, () => service(ctx)); diff --git a/apps/server/test/modules.test.ts b/apps/server/test/modules.test.ts index f8e9181d..66e52e0c 100644 --- a/apps/server/test/modules.test.ts +++ b/apps/server/test/modules.test.ts @@ -1,8 +1,8 @@ import { createRoute, z } from "@hono/zod-openapi"; -import { apiResponse, CAPABILITY_IDS, capabilitySpec } from "@umt/contracts"; +import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS } from "@umt/contracts"; import { defineTask, TASK_NAMES, TaskRegistry } from "@umt/tasks"; import { describe, expect, it } from "vitest"; -import { buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts"; +import { buildHookRegistry, buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts"; import { createApp } from "../src/http/app.ts"; import { requireScope } from "../src/http/auth.ts"; import { EventBus } from "../src/http/sse.ts"; @@ -103,7 +103,7 @@ describe("ServerModule 骨架", () => { expect(TASK_NAMES.filter((n) => !owned.has(n))).toEqual([]); }); - it("outbox 路由合并;同一事件被两个模块认领报错", () => { + it("outbox 路由合并;同一非广播事件被两个模块认领报错", () => { const merged = Object.keys(collectOutboxRoutes([...SERVER_MODULES, pingModule], fakeCtx)); expect(merged).toContain("test.ping"); expect(merged).toContain("release.resolved"); @@ -111,6 +111,94 @@ describe("ServerModule 骨架", () => { expect(() => collectOutboxRoutes([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复认领/); }); + it("生命周期广播事件可以被多个模块订阅:按模块顺序合成,各自的任务都投递,某个订阅方出错不连累别人", () => { + const { logger } = memoryLogger(); + const ctx = { role: "all", logger } as unknown as ServerContext; + const sub = (name: string, fn: () => unknown): ServerModule => ({ + name, + registerRoutes() {}, + registerTasks() {}, + outboxRoutes: () => ({ "organization.finished": fn as never }), + }); + const routes = collectOutboxRoutes( + [ + ...SERVER_MODULES, + sub("a", () => ({ task: "taskA", input: { n: 1 } })), + sub("broken", () => { + throw new Error("坏了"); + }), + sub("b", () => [{ task: "taskB", input: {} }]), + sub("none", () => null), + ], + ctx, + ); + const ev = { + id: "e1", + type: "organization.finished", + at: new Date(), + payload: { + recordId: "00000000-0000-4000-8000-000000000001", + outcome: "succeeded", + action: "copy", + trigger: "auto", + mediaEntityId: "00000000-0000-4000-8000-000000000002", + downloadTaskId: null, + reason: null, + changeSeq: "7", + }, + }; + // 补字幕(样板)订阅了整理结束,返回 null(自动补字幕还没做) + expect(routes["organization.finished"]!(ev)).toEqual([ + { task: "taskA", input: { n: 1 } }, + { task: "taskB", input: {} }, + ]); + // 只有补字幕一个订阅方时它自己的路由不建任务 + expect(collectOutboxRoutes(SERVER_MODULES, ctx)["organization.finished"]!(ev)).toBeNull(); + // 非广播事件仍只能一个模块认领(release.observed 由 sources-identity 认领) + expect(() => collectOutboxRoutes([...SERVER_MODULES, sub("x", () => null)], ctx)).not.toThrow(); + const dup: ServerModule = { ...sub("y", () => null), outboxRoutes: () => ({ "release.observed": () => null }) }; + expect(() => collectOutboxRoutes([...SERVER_MODULES, dup], ctx)).toThrow(/重复认领/); + // 广播事件都登记了 payload schema + expect(LIFECYCLE_EVENTS.length).toBeGreaterThanOrEqual(17); + }); + + it("功能清单:外围功能模块都声明 manifest,与 contracts FEATURES 一一对应", () => { + const declared = SERVER_MODULES.filter((m) => m.manifest).map((m) => [m.name, m.manifest!.id]); + for (const [name, id] of declared) expect(id).toBe(name); + expect(declared.map(([n]) => n).sort()).toEqual(FEATURES.map((f) => f.id).sort()); + const subtitles = SERVER_MODULES.find((m) => m.name === "subtitles")!; + expect(subtitles.manifest?.subscribes).toEqual(["organization.finished"]); + expect(Object.keys(subtitles.outboxRoutes?.(fakeCtx) ?? {})).toEqual(subtitles.manifest?.subscribes); + }); + + it("链式钩子按模块顺序注册;生产装配目前没有模块注册钩子(无注册零开销)", async () => { + const order: string[] = []; + const mod = (name: string): ServerModule => ({ + name, + registerRoutes() {}, + registerTasks() {}, + registerHooks(hooks) { + hooks.rewrite("organize.rename", (n) => { + order.push(name); + return `${n}.${name}`; + }); + }, + }); + const hooks = buildHookRegistry([mod("first"), mod("second")], fakeCtx); + expect( + await hooks.runRewrite("organize.rename", "a", { + targetPath: "/a", + sourcePath: "/b", + mediaEntityId: "e", + mediaType: "movie", + season: null, + episodes: null, + }), + ).toBe("a.first.second"); + expect(order).toEqual(["first", "second"]); + expect(buildHookRegistry(SERVER_MODULES, fakeCtx).list()).toEqual({}); + }); + it("引擎就绪前调用给出明确错误", async () => { const e = new DeferredTaskEngine(); expect(e.ready).toBe(false); diff --git a/apps/server/test/subtitles/subtitle-upload.test.ts b/apps/server/test/subtitles/subtitle-upload.test.ts index 268b3e13..3a1bfd1b 100644 --- a/apps/server/test/subtitles/subtitle-upload.test.ts +++ b/apps/server/test/subtitles/subtitle-upload.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { parseEventPayload } from "@umt/contracts"; import { zipSync } from "fflate"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createSubtitlesModule } from "../../src/modules/subtitles/index.ts"; @@ -159,6 +160,18 @@ describe.skipIf(!hasPg)("补字幕:上传、写入、撤销(真实 PG + 临时 const hist = await api("GET", `/subtitles/writes?entityId=${entityId}`); expect(hist.body.data.items).toHaveLength(2); + // 生命周期广播(ADR-006):每个写入一条 subtitle.written,payload 过 contracts schema + const events = await h.db + .selectFrom("outbox_event") + .select(["payload", "idempotency_key"]) + .where("type", "=", "subtitle.written") + .orderBy("at") + .execute(); + expect(events.map((e) => parseEventPayload("subtitle.written", e.payload))).toEqual([ + { writeId: w1.id, mediaEntityId: entityId, videoLocationId: loc.e1, language: "zh-Hant", source: "upload" }, + { writeId: w2.id, mediaEntityId: entityId, videoLocationId: loc.e2, language: "zh-Hans&en", source: "upload" }, + ]); + expect(events.map((e) => e.idempotency_key)).toEqual([`subtitle.written/${w1.id}`, `subtitle.written/${w2.id}`]); expect((await api("POST", `/subtitles/writes/${w2.id}/undo`)).body.data.undoneAt).toBeTruthy(); expect(await readFile(join(dir, "示例剧 - S01E02.chi.zh-cn.srt"), "utf8")).toBe("旧字幕"); diff --git a/docs/plan/subtitles.md b/docs/plan/subtitles.md index 8a0e6a8f..52f0fe25 100644 --- a/docs/plan/subtitles.md +++ b/docs/plan/subtitles.md @@ -2,6 +2,8 @@ 参考 MoviePilot 插件「海拉鲁字幕大师」(ifsherlock/MoviePilot-Plugins `plugins.v3/subtitlemanualupload` 1.2.6)。本项目只取**手动上传**和**在线搜索**;调轴、AI 翻译联动、入库后自动补字幕不做。自动补字幕以后接进我们自己的工作流(整理完成、订阅缺字幕、智能体调用),复用本模块的能力,不照插件的事件队列做。 +功能模块样板(ADR-006,2026-10-06):本模块已声明 manifest、订阅 `organization.finished`(认出值得补字幕的整理,自动补字幕还没做,只记调试日志)、写入后发 `subtitle.written`;前端经插槽挂在作品页操作区与整理记录行菜单。做自动补字幕时把订阅路由换成真实任务即可。 + ## 拆分 | 层面 | 放在哪 | diff --git a/packages/agent/src/prompt/index.ts b/packages/agent/src/prompt/index.ts index 814d7c9a..46458695 100644 --- a/packages/agent/src/prompt/index.ts +++ b/packages/agent/src/prompt/index.ts @@ -18,6 +18,7 @@ export { type IntentRoute, needsApproval, type RouteStep, + renderFeatures, } from "./layers/capabilities.ts"; export { renderEnvironment } from "./layers/environment.ts"; export { AGENT_REPORT_FORMAT, REPORT_FORMAT } from "./layers/report.ts"; diff --git a/packages/agent/src/prompt/layers/capabilities.ts b/packages/agent/src/prompt/layers/capabilities.ts index 0202b62b..7966e230 100644 --- a/packages/agent/src/prompt/layers/capabilities.ts +++ b/packages/agent/src/prompt/layers/capabilities.ts @@ -1,4 +1,4 @@ -import { AUTHORIZED_KINDS, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts"; +import { AUTHORIZED_KINDS, CAPABILITY_CATALOG, FEATURES, type KnownCapabilityId } from "@umt/contracts"; import type { PromptSection } from "../types.ts"; // 第 3 层:能力地图,按用户意图组织("要查健康 → …","要查下载 → …")。工具描述里已有每个能力"何时用 / 何时不用", @@ -190,6 +190,20 @@ export function renderRoutes(available: ReadonlySet): string[] { return out; } +/** + * 功能清单(ADR-006):外围功能各一行,说明做什么、入口在哪、智能体能不能做。清单来自 contracts 的 FEATURES + * (服务端模块的 manifest 指向同一份),功能都已启用、没有开关;能力只列本次可用的。 + */ +export function renderFeatures(available: ReadonlySet): string[] { + return [ + "- 外围功能(都已启用,没有开关;用户问到时按这里指路):", + ...FEATURES.map((f) => { + const caps = f.capabilities.filter((c) => available.has(c)); + return ` - ${f.name}:${f.summary}${f.usage}${caps.length ? `(能力:${caps.join("、")})` : ""}`; + }), + ]; +} + export function capabilityLayer(capabilities: readonly string[] | undefined): PromptSection { if (!capabilities?.length) return { layer: "capabilities", title: "能力地图", lines: [] }; const available = new Set(capabilities); @@ -202,6 +216,7 @@ export function capabilityLayer(capabilities: readonly string[] | undefined): Pr "按用户意图选路线,箭头是常用顺序;只做回答问题需要的几步。标「需要批准」的没有授权时会被拒绝或出审批卡。", ...routes, ...CAPABILITY_TIPS.map((t) => `- ${t}`), + ...renderFeatures(available), ], }; } diff --git a/packages/agent/test/features-prompt.test.ts b/packages/agent/test/features-prompt.test.ts new file mode 100644 index 00000000..6de8327e --- /dev/null +++ b/packages/agent/test/features-prompt.test.ts @@ -0,0 +1,35 @@ +import { CAPABILITY_CATALOG, FEATURES } from "@umt/contracts"; +import { describe, expect, it } from "vitest"; +import { buildLayeredPrompt, renderFeatures } from "../src/index.ts"; + +// 功能清单(ADR-006):外围功能的 manifest 进系统提示「能力地图」一节,让智能体知道有哪些功能、入口在哪;没有开关。 + +const section = (p: string, title: string) => { + const start = p.indexOf(`## ${title}`); + const rest = p.slice(start); + const next = rest.indexOf("\n## ", 3); + return next < 0 ? rest : rest.slice(0, next); +}; + +describe("功能清单进系统提示", () => { + it("能力地图里列出每个外围功能的名称、说明与入口", () => { + const p = buildLayeredPrompt({ kind: "chat", role: "x", goal: "y", capabilities: ["download.list"] }); + const map = section(p, "能力地图"); + expect(map).toContain("外围功能(都已启用,没有开关"); + for (const f of FEATURES) expect(map).toContain(` - ${f.name}:${f.summary}`); + expect(map).toContain("作品页「补字幕」"); + }); + + it("功能的能力只列本次可用的;没给的能力 ID 不出现在清单里", () => { + const lines = renderFeatures(new Set(["web.search"])); + const web = lines.find((l) => l.includes("网页搜索"))!; + expect(web).toContain("(能力:web.search)"); + const text = lines.join("\n"); + for (const id of Object.keys(CAPABILITY_CATALOG)) if (id !== "web.search") expect(text).not.toContain(id); + }); + + it("没有能力的会话(视觉子流程)不带功能清单", () => { + const p = buildLayeredPrompt({ kind: "vision", role: "x", goal: "y" }); + expect(p).not.toContain("外围功能"); + }); +});