forked from bai/curriculum-project-hub
feat(hub): 抽出运行时配置
This commit is contained in:
@@ -4,10 +4,35 @@ import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
|
||||
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
|
||||
import type { RuntimeSettings } from "../../src/settings/runtime.js";
|
||||
|
||||
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
||||
type TestRunner = (req: RunRequest) => Promise<RunResult>;
|
||||
|
||||
function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
|
||||
return {
|
||||
async provider(providerId) {
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
authToken: "test-token",
|
||||
anthropicApiKey: "",
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
async modelRegistry() {
|
||||
return models;
|
||||
},
|
||||
async runPolicy() {
|
||||
return { maxTurns: 7 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user", eventId?: string): MessageReceiveEvent {
|
||||
const header = eventId !== undefined ? { event_id: eventId, event_type: "im.message.receive_v1" } : undefined;
|
||||
return {
|
||||
@@ -49,6 +74,7 @@ function createMockRunAgent(calls: RunRequest[] = []): TestRunner {
|
||||
|
||||
describe("trigger full lifecycle (integration)", () => {
|
||||
let models: InMemoryModelRegistry;
|
||||
let settings: RuntimeSettings;
|
||||
let rt: ReturnType<typeof mockFeishuRuntime>;
|
||||
let runAgentCalls: RunRequest[];
|
||||
let runAgent: TestRunner;
|
||||
@@ -64,12 +90,13 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
{ id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] },
|
||||
],
|
||||
);
|
||||
settings = makeTestSettings(models);
|
||||
rt = mockFeishuRuntime();
|
||||
});
|
||||
|
||||
it("creates a run, acquires + releases the lock, sends status card", async () => {
|
||||
await seedProject("proj-1", "chat-1");
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt);
|
||||
|
||||
@@ -90,11 +117,17 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(rt.sentCards.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rt.sentTexts).toContain("mock response");
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
expect(runAgentCalls[0]?.providerEnv).toMatchObject({
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
});
|
||||
expect(runAgentCalls[0]?.maxTurns).toBe(7);
|
||||
});
|
||||
|
||||
it("rejects a sender without edit grant (ADR-0004)", async () => {
|
||||
await seedProject("proj-2", "chat-2", { role: "READ" });
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
|
||||
|
||||
@@ -114,7 +147,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
data: { projectId: "proj-3", runId: existingRun.id },
|
||||
});
|
||||
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
|
||||
@@ -126,7 +159,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("ignores messages from unbound chats (ADR-0001)", async () => {
|
||||
await seedProject("proj-4", "chat-4");
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
|
||||
|
||||
@@ -138,7 +171,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("ignores messages without @bot mention", async () => {
|
||||
await seedProject("proj-5", "chat-5");
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
const event: MessageReceiveEvent = {
|
||||
message: {
|
||||
@@ -160,7 +193,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("/new archives current session (no run created)", async () => {
|
||||
await seedProject("proj-6", "chat-6");
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
// First @bot creates a session + run.
|
||||
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
|
||||
@@ -183,7 +216,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("/resume un-archives the most recent session", async () => {
|
||||
await seedProject("proj-7", "chat-7");
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
// Create + archive a session via /new.
|
||||
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
|
||||
@@ -203,7 +236,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("/reset archives current session", async () => {
|
||||
await seedProject("proj-8", "chat-8");
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
@@ -222,7 +255,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("unknown slash command falls through to agent", async () => {
|
||||
await seedProject("proj-9", "chat-9");
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
|
||||
// Should create a run (falls through as a normal prompt).
|
||||
@@ -239,7 +272,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: "proj-10", roleId: "review", principal: "ou_other" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
|
||||
|
||||
@@ -254,7 +287,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" },
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt);
|
||||
|
||||
@@ -268,7 +301,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => {
|
||||
await seedProject("proj-12", "chat-12");
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt);
|
||||
|
||||
@@ -282,7 +315,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("dedups a redelivered event by event_id (no second run)", async () => {
|
||||
await seedProject("proj-13", "chat-13");
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
// First delivery: processes normally.
|
||||
await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt);
|
||||
@@ -305,7 +338,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("writes audit entries across the run lifecycle", async () => {
|
||||
await seedProject("proj-14", "chat-14");
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
await trigger(makeEvent("chat-14", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
@@ -320,7 +353,7 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
|
||||
it("persists AgentMessage rows for the run (A-3 structured history)", async () => {
|
||||
await seedProject("proj-15", "chat-15");
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent });
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent });
|
||||
|
||||
await trigger(makeEvent("chat-15", "@_user_1 写教案"), rt);
|
||||
await vi.waitFor(async () => {
|
||||
|
||||
@@ -91,6 +91,36 @@ describe("runAgent", () => {
|
||||
expect(call?.options).not.toHaveProperty("resume");
|
||||
});
|
||||
|
||||
it("passes provider env per run without mutating process env", async () => {
|
||||
delete process.env["CPH_TEST_PROVIDER_ENV_MUTATION"];
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||
|
||||
await runAgent({
|
||||
prompt: "你好",
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" },
|
||||
systemPrompt: undefined,
|
||||
providerEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
|
||||
},
|
||||
runId: "run-1",
|
||||
sessionId: "hub-session-1",
|
||||
prisma: stubPrisma,
|
||||
});
|
||||
|
||||
const call = queryMock.mock.calls[0]?.[0] as { options?: { env?: Record<string, string | undefined> } } | undefined;
|
||||
expect(call?.options?.env).toMatchObject({
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
CPH_TEST_PROVIDER_ENV_MUTATION: "per-run",
|
||||
});
|
||||
expect(process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists structured user and assistant messages best-effort", async () => {
|
||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||
const createMessage = vi.fn().mockResolvedValue({});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createEnvRuntimeSettings } from "../../src/settings/runtime.js";
|
||||
|
||||
describe("runtime settings", () => {
|
||||
it("reads the model registry from the current env at call time", async () => {
|
||||
const env: Record<string, string | undefined> = {
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-5",
|
||||
};
|
||||
const settings = createEnvRuntimeSettings(env);
|
||||
|
||||
expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("anthropic/claude-sonnet-5");
|
||||
|
||||
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = "z-ai/glm-4.7";
|
||||
|
||||
expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("z-ai/glm-4.7");
|
||||
});
|
||||
|
||||
it("returns provider SDK env without requiring global process env mutation", async () => {
|
||||
const settings = createEnvRuntimeSettings({
|
||||
ANTHROPIC_BASE_URL: "https://example.test/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
|
||||
ANTHROPIC_API_KEY: "explicit-api-key",
|
||||
});
|
||||
|
||||
await expect(settings.provider("openrouter")).resolves.toMatchObject({
|
||||
id: "openrouter",
|
||||
baseUrl: "https://example.test/api",
|
||||
authToken: "openrouter-token",
|
||||
anthropicApiKey: "explicit-api-key",
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://example.test/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
|
||||
ANTHROPIC_API_KEY: "explicit-api-key",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults Anthropic API key to empty for OpenRouter's Anthropic skin", async () => {
|
||||
const settings = createEnvRuntimeSettings({
|
||||
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
|
||||
});
|
||||
|
||||
await expect(settings.provider("openrouter")).resolves.toMatchObject({
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
anthropicApiKey: "",
|
||||
sdkEnv: {
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requires an auth token for the OpenRouter provider", async () => {
|
||||
const settings = createEnvRuntimeSettings({});
|
||||
|
||||
await expect(settings.provider("openrouter")).rejects.toThrow("missing required runtime setting: ANTHROPIC_AUTH_TOKEN");
|
||||
});
|
||||
|
||||
it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => {
|
||||
const settings = createEnvRuntimeSettings({
|
||||
HUB_AGENT_MAX_TURNS: "9",
|
||||
});
|
||||
|
||||
await expect(settings.runPolicy({ projectId: "p", roleId: "draft" })).resolves.toEqual({ maxTurns: 9 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user