feat: 功能清单进智能体提示,补字幕按功能模块约定改为样板
- 外围功能模块(补字幕、原站视图、桌面模式、网页搜索、MCP、定时任务)声明 manifest,与 FEATURES 一一对应; 系统提示「能力地图」末尾列出外围功能(没有开关,能力只列本次可用的) - 补字幕:订阅 organization.finished(认出值得补字幕的整理,自动补字幕还没做,不建任务), 写入字幕后在同一事务发 subtitle.written;原有行为不变 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
1db689d9a7
commit
7aa5182f3d
14 files changed
+237
-17
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user