forked from EduCraft/curriculum-project-hub
feat(hub): 抽出运行时配置
This commit is contained in:
@@ -18,6 +18,9 @@ ANTHROPIC_API_KEY=""
|
|||||||
# ANTHROPIC_DEFAULT_OPUS_MODEL="z-ai/glm-4.7"
|
# ANTHROPIC_DEFAULT_OPUS_MODEL="z-ai/glm-4.7"
|
||||||
# ANTHROPIC_DEFAULT_HAIKU_MODEL="z-ai/glm-4.6"
|
# ANTHROPIC_DEFAULT_HAIKU_MODEL="z-ai/glm-4.6"
|
||||||
|
|
||||||
|
# Agent run policy (optional). Defaults to 25.
|
||||||
|
# HUB_AGENT_MAX_TURNS=25
|
||||||
|
|
||||||
# Feishu (lark) bot credentials.
|
# Feishu (lark) bot credentials.
|
||||||
FEISHU_APP_ID=""
|
FEISHU_APP_ID=""
|
||||||
FEISHU_APP_SECRET=""
|
FEISHU_APP_SECRET=""
|
||||||
|
|||||||
@@ -1,32 +1,5 @@
|
|||||||
import { InMemoryModelRegistry } from "./models.js";
|
export {
|
||||||
|
createDefaultModelRegistry,
|
||||||
type Env = Readonly<Record<string, string | undefined>>;
|
defaultSonnetModel,
|
||||||
|
type Env,
|
||||||
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
|
} from "../settings/runtime.js";
|
||||||
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
|
|
||||||
|
|
||||||
function readEnv(env: Env, name: string): string | undefined {
|
|
||||||
const value = env[name]?.trim();
|
|
||||||
return value === undefined || value === "" ? undefined : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultSonnetModel(env: Env = process.env): string {
|
|
||||||
return readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL") ?? DEFAULT_SONNET_MODEL;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDefaultModelRegistry(env: Env = process.env): InMemoryModelRegistry {
|
|
||||||
const sonnetModel = defaultSonnetModel(env);
|
|
||||||
const sonnetLabel =
|
|
||||||
readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME") ??
|
|
||||||
(sonnetModel === DEFAULT_SONNET_MODEL ? DEFAULT_SONNET_LABEL : sonnetModel);
|
|
||||||
|
|
||||||
return new InMemoryModelRegistry(
|
|
||||||
[
|
|
||||||
{ id: sonnetModel, label: sonnetLabel, toolCapable: true },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "draft", label: "草稿", defaultModel: sonnetModel },
|
|
||||||
{ id: "review", label: "审校", defaultModel: sonnetModel },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface RunRequest {
|
|||||||
readonly model: string | undefined;
|
readonly model: string | undefined;
|
||||||
readonly project: ProjectContext;
|
readonly project: ProjectContext;
|
||||||
readonly systemPrompt: string | undefined;
|
readonly systemPrompt: string | undefined;
|
||||||
|
readonly providerEnv?: Record<string, string | undefined> | undefined;
|
||||||
readonly resumeSessionId?: string | undefined;
|
readonly resumeSessionId?: string | undefined;
|
||||||
readonly mcpServers?: Record<string, McpServerConfig> | undefined;
|
readonly mcpServers?: Record<string, McpServerConfig> | undefined;
|
||||||
readonly maxTurns?: number;
|
readonly maxTurns?: number;
|
||||||
@@ -146,6 +147,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
|||||||
};
|
};
|
||||||
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
|
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
|
||||||
if (req.model !== undefined) options.model = req.model;
|
if (req.model !== undefined) options.model = req.model;
|
||||||
|
if (req.providerEnv !== undefined) options.env = { ...process.env, ...req.providerEnv };
|
||||||
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
|
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
|
||||||
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
|
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import type { Prisma, PrismaClient } from "@prisma/client";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
||||||
import type { ModelRegistry } from "../agent/models.js";
|
|
||||||
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
|
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
|
||||||
|
import type { RuntimeSettings } from "../settings/runtime.js";
|
||||||
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||||
import { canTriggerAgent, canTriggerRole } from "../permission.js";
|
import { canTriggerAgent, canTriggerRole } from "../permission.js";
|
||||||
import { writeAudit } from "../audit.js";
|
import { writeAudit } from "../audit.js";
|
||||||
@@ -22,7 +22,7 @@ import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
|||||||
|
|
||||||
interface TriggerDeps {
|
interface TriggerDeps {
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
readonly models: ModelRegistry;
|
readonly settings: RuntimeSettings;
|
||||||
readonly logger: FastifyBaseLogger;
|
readonly logger: FastifyBaseLogger;
|
||||||
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
|
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,8 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
|
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
|
||||||
// slashes are left as literal text (extractRole returns null). Falls back
|
// slashes are left as literal text (extractRole returns null). Falls back
|
||||||
// to "draft" when no role is named — the registry degrades gracefully.
|
// to "draft" when no role is named — the registry degrades gracefully.
|
||||||
const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, deps.models);
|
const models = await deps.settings.modelRegistry({ projectId });
|
||||||
|
const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, models);
|
||||||
const roleId = parsedRole ?? "draft";
|
const roleId = parsedRole ?? "draft";
|
||||||
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
||||||
// canTriggerAgent above: that checked "can trigger an agent at all";
|
// canTriggerAgent above: that checked "can trigger an agent at all";
|
||||||
@@ -195,9 +196,11 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const role = deps.models.role(roleId);
|
const role = models.role(roleId);
|
||||||
const model = deps.models.resolve(undefined, roleId);
|
const model = models.resolve(undefined, roleId);
|
||||||
const providerId = "openrouter";
|
const provider = await deps.settings.provider("openrouter", { projectId });
|
||||||
|
const runPolicy = await deps.settings.runPolicy({ projectId, roleId });
|
||||||
|
const providerId = provider.id;
|
||||||
// Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist
|
// Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist
|
||||||
// is enforced inside runAgent when it builds the SDK tools record. `tools:
|
// is enforced inside runAgent when it builds the SDK tools record. `tools:
|
||||||
// undefined` means the full registered set (unrestricted role).
|
// undefined` means the full registered set (unrestricted role).
|
||||||
@@ -289,8 +292,10 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
model,
|
model,
|
||||||
project: projectCtx,
|
project: projectCtx,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
|
providerEnv: provider.sdkEnv,
|
||||||
resumeSessionId: sessionMetadata.claudeSessionId,
|
resumeSessionId: sessionMetadata.claudeSessionId,
|
||||||
mcpServers: { cph_hub: fileDeliveryMcpServer },
|
mcpServers: { cph_hub: fileDeliveryMcpServer },
|
||||||
|
maxTurns: runPolicy.maxTurns,
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
prisma: deps.prisma,
|
prisma: deps.prisma,
|
||||||
|
|||||||
+5
-13
@@ -13,7 +13,7 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import { prisma } from "./db.js";
|
import { prisma } from "./db.js";
|
||||||
import { createDefaultModelRegistry } from "./agent/defaultModels.js";
|
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
||||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||||
|
|
||||||
@@ -36,11 +36,9 @@ async function main(): Promise<void> {
|
|||||||
const databaseUrl = requireEnv("DATABASE_URL");
|
const databaseUrl = requireEnv("DATABASE_URL");
|
||||||
void databaseUrl;
|
void databaseUrl;
|
||||||
|
|
||||||
// Claude Code SDK auth via OpenRouter's Anthropic Skin.
|
const runtimeSettings = createEnvRuntimeSettings();
|
||||||
// ANTHROPIC_AUTH_TOKEN = OpenRouter API key; ANTHROPIC_API_KEY must be empty.
|
await runtimeSettings.provider("openrouter");
|
||||||
requireEnv("ANTHROPIC_AUTH_TOKEN");
|
|
||||||
process.env["ANTHROPIC_BASE_URL"] ??= "https://openrouter.ai/api";
|
|
||||||
process.env["ANTHROPIC_API_KEY"] ??= "";
|
|
||||||
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||||
@@ -56,20 +54,14 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
app.log.info("startup: cleared stale locks + dead runs");
|
app.log.info("startup: cleared stale locks + dead runs");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
||||||
|
|
||||||
// Model registry (role-as-data, ADR-0017). Defaults are OpenRouter model
|
|
||||||
// slugs; ANTHROPIC_DEFAULT_SONNET_MODEL can override the role default.
|
|
||||||
const models = createDefaultModelRegistry();
|
|
||||||
|
|
||||||
// --- Feishu listener ---
|
// --- Feishu listener ---
|
||||||
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||||
if (feishuListenerEnabled) {
|
if (feishuListenerEnabled) {
|
||||||
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
||||||
const larkClient = createLarkClient(feishuConfig);
|
const larkClient = createLarkClient(feishuConfig);
|
||||||
const trigger = makeTriggerHandler({ prisma, models, logger: app.log });
|
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log });
|
||||||
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger);
|
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger);
|
||||||
} else {
|
} else {
|
||||||
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js";
|
||||||
|
|
||||||
|
export type Env = Readonly<Record<string, string | undefined>>;
|
||||||
|
|
||||||
|
type EnvSource = Env | (() => Env);
|
||||||
|
|
||||||
|
const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api";
|
||||||
|
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
|
||||||
|
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
|
||||||
|
const DEFAULT_AGENT_MAX_TURNS = 25;
|
||||||
|
|
||||||
|
export interface ProviderRuntimeSettings {
|
||||||
|
readonly id: string;
|
||||||
|
readonly baseUrl: string;
|
||||||
|
readonly authToken: string;
|
||||||
|
readonly anthropicApiKey: string;
|
||||||
|
readonly sdkEnv: Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeScope {
|
||||||
|
readonly projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunPolicyInput {
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly roleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunPolicy {
|
||||||
|
readonly maxTurns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeSettings {
|
||||||
|
provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings>;
|
||||||
|
modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry>;
|
||||||
|
runPolicy(input: RunPolicyInput): Promise<RunPolicy>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EnvRuntimeSettings implements RuntimeSettings {
|
||||||
|
private readonly envSource: () => Env;
|
||||||
|
|
||||||
|
constructor(env: EnvSource = process.env) {
|
||||||
|
this.envSource = typeof env === "function" ? env : () => env;
|
||||||
|
}
|
||||||
|
|
||||||
|
async provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings> {
|
||||||
|
void scope;
|
||||||
|
if (providerId !== "openrouter") {
|
||||||
|
throw new Error(`unknown provider runtime settings: ${providerId}`);
|
||||||
|
}
|
||||||
|
const env = this.envSource();
|
||||||
|
const baseUrl = readEnv(env, "ANTHROPIC_BASE_URL") ?? DEFAULT_OPENROUTER_BASE_URL;
|
||||||
|
const authToken = requireSetting(env, "ANTHROPIC_AUTH_TOKEN");
|
||||||
|
const anthropicApiKey = readEnv(env, "ANTHROPIC_API_KEY") ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: providerId,
|
||||||
|
baseUrl,
|
||||||
|
authToken,
|
||||||
|
anthropicApiKey,
|
||||||
|
sdkEnv: {
|
||||||
|
ANTHROPIC_BASE_URL: baseUrl,
|
||||||
|
ANTHROPIC_AUTH_TOKEN: authToken,
|
||||||
|
ANTHROPIC_API_KEY: anthropicApiKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
|
||||||
|
void scope;
|
||||||
|
return createDefaultModelRegistry(this.envSource());
|
||||||
|
}
|
||||||
|
|
||||||
|
async runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
||||||
|
void input;
|
||||||
|
return {
|
||||||
|
maxTurns: positiveIntegerEnv(this.envSource(), "HUB_AGENT_MAX_TURNS", DEFAULT_AGENT_MAX_TURNS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEnvRuntimeSettings(env: EnvSource = process.env): RuntimeSettings {
|
||||||
|
return new EnvRuntimeSettings(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultSonnetModel(env: Env = process.env): string {
|
||||||
|
return readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL") ?? DEFAULT_SONNET_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultModelRegistry(env: Env = process.env): InMemoryModelRegistry {
|
||||||
|
const sonnetModel = defaultSonnetModel(env);
|
||||||
|
const sonnetLabel =
|
||||||
|
readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME") ??
|
||||||
|
(sonnetModel === DEFAULT_SONNET_MODEL ? DEFAULT_SONNET_LABEL : sonnetModel);
|
||||||
|
|
||||||
|
return new InMemoryModelRegistry(
|
||||||
|
[
|
||||||
|
{ id: sonnetModel, label: sonnetLabel, toolCapable: true },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ id: "draft", label: "草稿", defaultModel: sonnetModel },
|
||||||
|
{ id: "review", label: "审校", defaultModel: sonnetModel },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnv(env: Env, name: string): string | undefined {
|
||||||
|
const value = env[name]?.trim();
|
||||||
|
return value === undefined || value === "" ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSetting(env: Env, name: string): string {
|
||||||
|
const value = readEnv(env, name);
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error(`missing required runtime setting: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveIntegerEnv(env: Env, name: string, fallback: number): number {
|
||||||
|
const raw = readEnv(env, name);
|
||||||
|
if (raw === undefined) return fallback;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
throw new Error(`runtime setting ${name} must be a positive integer`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -4,10 +4,35 @@ import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
|||||||
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||||
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
|
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
|
||||||
import type { RunRequest, RunResult } from "../../src/agent/runner.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" };
|
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
|
||||||
type TestRunner = (req: RunRequest) => Promise<RunResult>;
|
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 {
|
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;
|
const header = eventId !== undefined ? { event_id: eventId, event_type: "im.message.receive_v1" } : undefined;
|
||||||
return {
|
return {
|
||||||
@@ -49,6 +74,7 @@ function createMockRunAgent(calls: RunRequest[] = []): TestRunner {
|
|||||||
|
|
||||||
describe("trigger full lifecycle (integration)", () => {
|
describe("trigger full lifecycle (integration)", () => {
|
||||||
let models: InMemoryModelRegistry;
|
let models: InMemoryModelRegistry;
|
||||||
|
let settings: RuntimeSettings;
|
||||||
let rt: ReturnType<typeof mockFeishuRuntime>;
|
let rt: ReturnType<typeof mockFeishuRuntime>;
|
||||||
let runAgentCalls: RunRequest[];
|
let runAgentCalls: RunRequest[];
|
||||||
let runAgent: TestRunner;
|
let runAgent: TestRunner;
|
||||||
@@ -64,12 +90,13 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
{ id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] },
|
{ id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
settings = makeTestSettings(models);
|
||||||
rt = mockFeishuRuntime();
|
rt = mockFeishuRuntime();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a run, acquires + releases the lock, sends status card", async () => {
|
it("creates a run, acquires + releases the lock, sends status card", async () => {
|
||||||
await seedProject("proj-1", "chat-1");
|
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);
|
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.sentCards.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(rt.sentTexts).toContain("mock response");
|
expect(rt.sentTexts).toContain("mock response");
|
||||||
expect(runAgentCalls).toHaveLength(1);
|
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 () => {
|
it("rejects a sender without edit grant (ADR-0004)", async () => {
|
||||||
await seedProject("proj-2", "chat-2", { role: "READ" });
|
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);
|
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
|
||||||
|
|
||||||
@@ -114,7 +147,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
data: { projectId: "proj-3", runId: existingRun.id },
|
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);
|
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
|
||||||
|
|
||||||
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
|
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
|
||||||
@@ -126,7 +159,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("ignores messages from unbound chats (ADR-0001)", async () => {
|
it("ignores messages from unbound chats (ADR-0001)", async () => {
|
||||||
await seedProject("proj-4", "chat-4");
|
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);
|
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
|
||||||
|
|
||||||
@@ -138,7 +171,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("ignores messages without @bot mention", async () => {
|
it("ignores messages without @bot mention", async () => {
|
||||||
await seedProject("proj-5", "chat-5");
|
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 = {
|
const event: MessageReceiveEvent = {
|
||||||
message: {
|
message: {
|
||||||
@@ -160,7 +193,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("/new archives current session (no run created)", async () => {
|
it("/new archives current session (no run created)", async () => {
|
||||||
await seedProject("proj-6", "chat-6");
|
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.
|
// First @bot creates a session + run.
|
||||||
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
|
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 () => {
|
it("/resume un-archives the most recent session", async () => {
|
||||||
await seedProject("proj-7", "chat-7");
|
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.
|
// Create + archive a session via /new.
|
||||||
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
|
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
|
||||||
@@ -203,7 +236,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("/reset archives current session", async () => {
|
it("/reset archives current session", async () => {
|
||||||
await seedProject("proj-8", "chat-8");
|
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 trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
|
||||||
await vi.waitFor(async () => {
|
await vi.waitFor(async () => {
|
||||||
@@ -222,7 +255,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("unknown slash command falls through to agent", async () => {
|
it("unknown slash command falls through to agent", async () => {
|
||||||
await seedProject("proj-9", "chat-9");
|
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);
|
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
|
||||||
// Should create a run (falls through as a normal prompt).
|
// Should create a run (falls through as a normal prompt).
|
||||||
@@ -239,7 +272,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
await prisma.roleTriggerGrant.create({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: "proj-10", roleId: "review", principal: "ou_other" },
|
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);
|
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
|
||||||
|
|
||||||
@@ -254,7 +287,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
await prisma.roleTriggerGrant.create({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" },
|
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);
|
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 () => {
|
it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => {
|
||||||
await seedProject("proj-12", "chat-12");
|
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);
|
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 () => {
|
it("dedups a redelivered event by event_id (no second run)", async () => {
|
||||||
await seedProject("proj-13", "chat-13");
|
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.
|
// First delivery: processes normally.
|
||||||
await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt);
|
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 () => {
|
it("writes audit entries across the run lifecycle", async () => {
|
||||||
await seedProject("proj-14", "chat-14");
|
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 trigger(makeEvent("chat-14", "@_user_1 写教案"), rt);
|
||||||
await vi.waitFor(async () => {
|
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 () => {
|
it("persists AgentMessage rows for the run (A-3 structured history)", async () => {
|
||||||
await seedProject("proj-15", "chat-15");
|
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 trigger(makeEvent("chat-15", "@_user_1 写教案"), rt);
|
||||||
await vi.waitFor(async () => {
|
await vi.waitFor(async () => {
|
||||||
|
|||||||
@@ -91,6 +91,36 @@ describe("runAgent", () => {
|
|||||||
expect(call?.options).not.toHaveProperty("resume");
|
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 () => {
|
it("persists structured user and assistant messages best-effort", async () => {
|
||||||
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
|
||||||
const createMessage = vi.fn().mockResolvedValue({});
|
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