added patch to attach and knowledge of trials
This commit is contained in:
@@ -34,6 +34,7 @@ export const claudeManagedEngine: AgentEngine = {
|
||||
onActionKeyed,
|
||||
onAgentReady,
|
||||
onApprovalsSuperseded,
|
||||
onThinking,
|
||||
org,
|
||||
providerUserId,
|
||||
thread,
|
||||
@@ -169,6 +170,7 @@ export const claudeManagedEngine: AgentEngine = {
|
||||
logger,
|
||||
onAction,
|
||||
onActionKeyed,
|
||||
onThinking,
|
||||
onTurnEnd,
|
||||
orgId: org.id,
|
||||
previewCapture,
|
||||
|
||||
@@ -36,6 +36,7 @@ export const runMessage = async ({
|
||||
onActionKeyed,
|
||||
onAgentReady,
|
||||
onApprovalsSuperseded,
|
||||
onThinking,
|
||||
onTurnComplete,
|
||||
providerUserId,
|
||||
recentMessages,
|
||||
@@ -142,6 +143,7 @@ export const runMessage = async ({
|
||||
onActionKeyed,
|
||||
onAgentReady,
|
||||
onApprovalsSuperseded,
|
||||
onThinking,
|
||||
org: {
|
||||
id: installation.org_id,
|
||||
slug: installation.org_slug ?? undefined,
|
||||
|
||||
@@ -42,6 +42,8 @@ export type MessageContext = {
|
||||
onApprovalsSuperseded?: (approvals: ChatApproval[]) => Promise<void> | void;
|
||||
/** Fires once the managed agent is ready to run its first turn (startup done). */
|
||||
onAgentReady?: () => Promise<void> | void;
|
||||
/** Fires when the agent starts an inference or emits thinking — drives the live status. */
|
||||
onThinking?: () => void;
|
||||
/** Posts a drained intermediate turn's text while follow-ups keep the run alive. */
|
||||
onTurnComplete?: (text: string) => Promise<void> | void;
|
||||
org: { id: string; slug?: string };
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type createPreviewCapture,
|
||||
getWriteToolForPreview,
|
||||
isSilentTool,
|
||||
toolLabel,
|
||||
toolGerund,
|
||||
} from "./toolPolicy.js";
|
||||
|
||||
type AutumnTool = {
|
||||
@@ -52,7 +52,7 @@ export const formatToolAction = ({
|
||||
typeof value === "string" && value ? [`${label}: ${value}`] : [],
|
||||
);
|
||||
|
||||
return `${toolLabel(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`;
|
||||
return `${toolGerund(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`;
|
||||
};
|
||||
|
||||
export const getAutumnMcpTools = async ({
|
||||
|
||||
@@ -35,6 +35,36 @@ export const toolLabel = (toolName: string) =>
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/^./, (char) => char.toUpperCase());
|
||||
|
||||
// Present-progressive phrasing for live status lines ("Looking up the
|
||||
// customer…"). Falls back to the noun label for anything unmapped.
|
||||
const gerunds: Record<string, string> = {
|
||||
getAgentRules: "Reading your billing setup",
|
||||
listPlans: "Looking through your plans",
|
||||
getPlan: "Pulling up the plan",
|
||||
listFeatures: "Reviewing the features",
|
||||
getCustomer: "Looking up the customer",
|
||||
getOrCreateCustomer: "Finding the customer",
|
||||
listCustomers: "Searching customers",
|
||||
getEntity: "Looking up the entity",
|
||||
listEntities: "Checking entities",
|
||||
getCurrentOrganization: "Checking your org",
|
||||
previewAttach: "Previewing the attach",
|
||||
previewCreateSchedule: "Previewing the schedule",
|
||||
previewUpdateSubscription: "Previewing the update",
|
||||
previewCreateBalance: "Previewing the balance change",
|
||||
attach: "Attaching the plan",
|
||||
createSchedule: "Scheduling the change",
|
||||
updateSubscription: "Updating the subscription",
|
||||
createBalance: "Updating the balance",
|
||||
createPlan: "Creating the plan",
|
||||
updateCustomer: "Updating the customer",
|
||||
searchRequestLogs: "Searching the logs",
|
||||
queryRequestLogs: "Querying the logs",
|
||||
};
|
||||
|
||||
export const toolGerund = (toolName: string) =>
|
||||
gerunds[normalizeToolName(toolName)] ?? toolLabel(toolName);
|
||||
|
||||
export type PreviewApproval = {
|
||||
preview: unknown;
|
||||
toolArgs: Record<string, unknown>;
|
||||
|
||||
@@ -36,13 +36,12 @@ import { findInstallationWithOrg } from "./providers/slack/installations.js";
|
||||
import { getRecentMessages } from "./providers/slack/threadContext.js";
|
||||
import type { ChatContextMessage } from "./types.js";
|
||||
import {
|
||||
createActionLogger,
|
||||
createKeyedActionLogger,
|
||||
finishLoading,
|
||||
type LoadingState,
|
||||
type ReplyTarget,
|
||||
startLoading,
|
||||
} from "./ui/progress.js";
|
||||
import { createStatusTicker } from "./ui/statusTicker.js";
|
||||
|
||||
export const chatAdapterNames = ["slack"];
|
||||
|
||||
@@ -146,6 +145,7 @@ const runAndReply = async ({
|
||||
let bootstrapLoading: LoadingState = null;
|
||||
let logger = rootLogger;
|
||||
let run: ActiveRun | undefined;
|
||||
const ticker = createStatusTicker(target);
|
||||
try {
|
||||
const workspaceId = getSlackWorkspaceId(raw);
|
||||
const installation = await findSlackInstallationForWorkspace({
|
||||
@@ -192,23 +192,23 @@ const runAndReply = async ({
|
||||
bootstrapLoading = isFollowUp
|
||||
? null
|
||||
: await startLoading(target, { showPlan: true });
|
||||
// The quiet "Working on it..." status only starts once the bootstrap card
|
||||
// The live status ticker only starts cycling once the bootstrap card
|
||||
// resolves, so the two loading states never show at the same time.
|
||||
const startWorkingStatus = () => startLoading(target, { showPlan: false });
|
||||
const completeBootstrap = async () => {
|
||||
if (!bootstrapLoading) return;
|
||||
const card = bootstrapLoading;
|
||||
bootstrapLoading = null;
|
||||
await finishLoading(target, card, "Autumn started.");
|
||||
await startWorkingStatus();
|
||||
ticker.thinking();
|
||||
};
|
||||
run = registerRun({ key: runKey, kind: "message" });
|
||||
// Follow-ups have no bootstrap card, so the quiet status starts right away.
|
||||
// Follow-ups have no bootstrap card, so the status starts right away.
|
||||
if (isFollowUp) {
|
||||
await startWorkingStatus();
|
||||
ticker.thinking();
|
||||
}
|
||||
const logAction = createActionLogger(loading, target);
|
||||
const logKeyed = createKeyedActionLogger(loading, target);
|
||||
const logAction = (message: string) => ticker.activity(message);
|
||||
const logKeyed = ({ message }: { key: string; message: string }) =>
|
||||
ticker.activity(message);
|
||||
run.logAction = logAction;
|
||||
const rawFiles = getSlackFilesFromRaw({ raw });
|
||||
const botToken = decrypt(installation.bot_access_token);
|
||||
@@ -229,6 +229,7 @@ const runAndReply = async ({
|
||||
onAgentReady: completeBootstrap,
|
||||
onApprovalsSuperseded: (approvals) =>
|
||||
editSupersededApprovalCards({ approvals, logger, target }),
|
||||
onThinking: ticker.thinking,
|
||||
onTurnComplete: async (turnText) => {
|
||||
await target.post({ markdown: turnText });
|
||||
},
|
||||
@@ -289,6 +290,7 @@ const runAndReply = async ({
|
||||
markdown: "I could not complete that request. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
ticker.stop();
|
||||
if (run) closeRun({ key: run.key, run });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,11 @@ import { setupAgentToolContext } from "../../agent/runMessage/setup/setupAgentTo
|
||||
import { env as chatEnv } from "../../lib/env.js";
|
||||
import { autumnChatInstructions } from "../common/instructions/index.js";
|
||||
import { claudeManagedConfig } from "./config.js";
|
||||
import {
|
||||
buildDesiredTools,
|
||||
builtinSignatureFromToolset,
|
||||
desiredBuiltinSignature,
|
||||
} from "./toolset.js";
|
||||
|
||||
const isLoopback = (hostname: string) =>
|
||||
hostname === "localhost" ||
|
||||
@@ -44,31 +49,43 @@ export const buildAgentSystem = ({ docsText }: { docsText: string }) =>
|
||||
[autumnChatInstructions, docsText].filter(Boolean).join("\n\n");
|
||||
|
||||
// Keep the shared agent config in sync with local code/tunnel changes.
|
||||
// Runs once per process, so restart is enough after prompt edits.
|
||||
// Re-syncs every turn in dev so prompt edits land without a restart; once per
|
||||
// process in prod.
|
||||
const alwaysResync = process.env.NODE_ENV !== "production";
|
||||
let agentConfigSynced = false;
|
||||
const syncAgentConfig = async ({
|
||||
agentId,
|
||||
client,
|
||||
destructiveTools,
|
||||
docsText,
|
||||
expectedUrl,
|
||||
logger,
|
||||
}: {
|
||||
agentId: string;
|
||||
client: Anthropic;
|
||||
destructiveTools: Iterable<string>;
|
||||
docsText: string;
|
||||
expectedUrl: string;
|
||||
logger: AutumnLogger;
|
||||
}) => {
|
||||
if (agentConfigSynced) return;
|
||||
if (agentConfigSynced && !alwaysResync) return;
|
||||
const agent = await client.beta.agents.retrieve(agentId);
|
||||
const expectedSystem = buildAgentSystem({ docsText });
|
||||
const currentUrl = agent.mcp_servers?.find(
|
||||
(server) => server.name === claudeManagedConfig.autumnMcpServerName,
|
||||
)?.url;
|
||||
const currentToolset = agent.tools?.find(
|
||||
(tool): tool is Extract<typeof tool, { type: "agent_toolset_20260401" }> =>
|
||||
tool.type === "agent_toolset_20260401",
|
||||
);
|
||||
const currentBuiltinSig = currentToolset
|
||||
? builtinSignatureFromToolset(currentToolset)
|
||||
: "";
|
||||
const mcpUrlChanged = currentUrl !== expectedUrl;
|
||||
const systemChanged = agent.system !== expectedSystem;
|
||||
const modelChanged = agent.model.id !== claudeManagedConfig.model;
|
||||
if (mcpUrlChanged || systemChanged || modelChanged) {
|
||||
const toolsChanged = currentBuiltinSig !== desiredBuiltinSignature();
|
||||
if (mcpUrlChanged || systemChanged || modelChanged || toolsChanged) {
|
||||
await client.beta.agents.update(agentId, {
|
||||
...(mcpUrlChanged
|
||||
? {
|
||||
@@ -83,6 +100,9 @@ const syncAgentConfig = async ({
|
||||
: {}),
|
||||
...(systemChanged ? { system: expectedSystem } : {}),
|
||||
...(modelChanged ? { model: claudeManagedConfig.model } : {}),
|
||||
...(toolsChanged
|
||||
? { tools: buildDesiredTools({ destructiveTools }) }
|
||||
: {}),
|
||||
version: agent.version,
|
||||
});
|
||||
logger.info("Refreshed Claude Managed agent config", {
|
||||
@@ -96,6 +116,9 @@ const syncAgentConfig = async ({
|
||||
model_changed: modelChanged,
|
||||
model_from: agent.model.id,
|
||||
model_to: claudeManagedConfig.model,
|
||||
tools_changed: toolsChanged,
|
||||
builtin_tools_from: currentBuiltinSig,
|
||||
builtin_tools_to: desiredBuiltinSignature(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -117,7 +140,9 @@ export const ensureLeafResources = async ({
|
||||
token: string;
|
||||
}): Promise<{ agentId: string; environmentId: string }> => {
|
||||
const mcpUrl = autumnMcpUrl();
|
||||
if (cachedResources && agentConfigSynced) return cachedResources;
|
||||
if (cachedResources && agentConfigSynced && !alwaysResync) {
|
||||
return cachedResources;
|
||||
}
|
||||
|
||||
const { destructiveTools, docsText } = await setupAgentToolContext({
|
||||
env,
|
||||
@@ -129,6 +154,7 @@ export const ensureLeafResources = async ({
|
||||
await syncAgentConfig({
|
||||
agentId: cachedResources.agentId,
|
||||
client,
|
||||
destructiveTools,
|
||||
docsText,
|
||||
expectedUrl: mcpUrl,
|
||||
logger,
|
||||
@@ -153,6 +179,7 @@ export const ensureLeafResources = async ({
|
||||
await syncAgentConfig({
|
||||
agentId,
|
||||
client,
|
||||
destructiveTools,
|
||||
docsText,
|
||||
expectedUrl: mcpUrl,
|
||||
logger,
|
||||
@@ -169,19 +196,7 @@ export const ensureLeafResources = async ({
|
||||
url: mcpUrl,
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
// Full sandboxed unix toolset + Autumn MCP — a real Claude Code, not locked down.
|
||||
{ type: "agent_toolset_20260401" },
|
||||
{
|
||||
default_config: { permission_policy: { type: "always_allow" } },
|
||||
mcp_server_name: claudeManagedConfig.autumnMcpServerName,
|
||||
type: "mcp_toolset",
|
||||
configs: [...destructiveTools].map((name) => ({
|
||||
name,
|
||||
permission_policy: { type: "always_ask" as const },
|
||||
})),
|
||||
},
|
||||
],
|
||||
tools: buildDesiredTools({ destructiveTools }),
|
||||
});
|
||||
agentId = agent.id;
|
||||
agentConfigSynced = true;
|
||||
|
||||
@@ -17,6 +17,7 @@ export const driveSessionTurn = async ({
|
||||
onAutumnToolResult,
|
||||
onSandboxTool,
|
||||
onSessionRetry,
|
||||
onThinking,
|
||||
onToolError,
|
||||
onTurnEnd,
|
||||
sessionId,
|
||||
@@ -39,6 +40,8 @@ export const driveSessionTurn = async ({
|
||||
name: string;
|
||||
}) => Promise<void> | void;
|
||||
onSessionRetry?: (input: { message: string }) => Promise<void> | void;
|
||||
/** Fires when the agent starts an inference or emits thinking — drives the "still working" status. */
|
||||
onThinking?: () => void;
|
||||
onToolError?: (input: {
|
||||
name: string;
|
||||
output: unknown;
|
||||
@@ -120,6 +123,10 @@ export const driveSessionTurn = async ({
|
||||
}
|
||||
} else if (event.type === "agent.tool_use") {
|
||||
await onSandboxTool?.({ input: event.input, name: event.name });
|
||||
} else if (event.type === "agent.thinking") {
|
||||
onThinking?.();
|
||||
} else if (event.type === "span.model_request_start") {
|
||||
onThinking?.();
|
||||
} else if (event.type === "span.model_request_end") {
|
||||
const usage = event.model_usage;
|
||||
outcome.usage.inputTokens += usage.input_tokens;
|
||||
|
||||
@@ -76,6 +76,7 @@ export const runClaudeManagedTurn = async ({
|
||||
logger,
|
||||
onAction,
|
||||
onActionKeyed,
|
||||
onThinking,
|
||||
onTurnEnd,
|
||||
orgId,
|
||||
previewCapture,
|
||||
@@ -89,6 +90,7 @@ export const runClaudeManagedTurn = async ({
|
||||
logger: AutumnLogger;
|
||||
onAction?: (message: string) => Promise<void> | void;
|
||||
onActionKeyed?: KeyedActionLogger;
|
||||
onThinking?: () => void;
|
||||
onTurnEnd?: (
|
||||
turn: SessionTurnOutcome,
|
||||
) => Promise<"continue" | "stop"> | "continue" | "stop";
|
||||
@@ -147,6 +149,7 @@ export const runClaudeManagedTurn = async ({
|
||||
});
|
||||
}
|
||||
},
|
||||
onThinking,
|
||||
onSessionRetry: async ({ message }) => {
|
||||
logger.warn("Claude Managed session retrying", {
|
||||
event: "leaf.claude_managed_session_retrying",
|
||||
|
||||
78
apps/leaf/src/harness/claudeManaged/toolset.ts
Normal file
78
apps/leaf/src/harness/claudeManaged/toolset.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { claudeManagedConfig } from "./config.js";
|
||||
|
||||
/** Built-in CMA sandbox tools the managed agent gets alongside Autumn MCP. Empty
|
||||
* omits the sandbox toolset entirely — smaller prefill and no sandbox provisioning,
|
||||
* since the billing agent only needs Autumn MCP tools. */
|
||||
export type ClaudeManagedBuiltinTool =
|
||||
| "bash"
|
||||
| "edit"
|
||||
| "read"
|
||||
| "write"
|
||||
| "glob"
|
||||
| "grep"
|
||||
| "web_fetch"
|
||||
| "web_search";
|
||||
|
||||
export const claudeManagedBuiltinTools: readonly ClaudeManagedBuiltinTool[] = [];
|
||||
|
||||
const ALL_BUILTIN_TOOLS: readonly ClaudeManagedBuiltinTool[] = [
|
||||
"bash",
|
||||
"edit",
|
||||
"read",
|
||||
"write",
|
||||
"glob",
|
||||
"grep",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
];
|
||||
|
||||
// Empty config omits the toolset entirely so the agent carries only Autumn MCP tools.
|
||||
const buildAgentToolset = () =>
|
||||
claudeManagedBuiltinTools.length === 0
|
||||
? undefined
|
||||
: {
|
||||
type: "agent_toolset_20260401" as const,
|
||||
default_config: { enabled: false },
|
||||
configs: claudeManagedBuiltinTools.map((name) => ({
|
||||
enabled: true as const,
|
||||
name,
|
||||
})),
|
||||
};
|
||||
|
||||
export const buildDesiredTools = ({
|
||||
destructiveTools,
|
||||
}: {
|
||||
destructiveTools: Iterable<string>;
|
||||
}) => {
|
||||
const toolset = buildAgentToolset();
|
||||
const mcpToolset = {
|
||||
configs: [...destructiveTools].map((name) => ({
|
||||
name,
|
||||
permission_policy: { type: "always_ask" as const },
|
||||
})),
|
||||
default_config: { permission_policy: { type: "always_allow" as const } },
|
||||
mcp_server_name: claudeManagedConfig.autumnMcpServerName,
|
||||
type: "mcp_toolset" as const,
|
||||
};
|
||||
return toolset ? [toolset, mcpToolset] : [mcpToolset];
|
||||
};
|
||||
|
||||
export const desiredBuiltinSignature = () =>
|
||||
[...claudeManagedBuiltinTools].sort().join(",");
|
||||
|
||||
export const builtinSignatureFromToolset = (toolset: {
|
||||
configs?: { enabled: boolean; name: string }[];
|
||||
default_config?: { enabled: boolean } | null;
|
||||
}) => {
|
||||
const enabled = new Set<string>(
|
||||
toolset.default_config?.enabled === false ? [] : ALL_BUILTIN_TOOLS,
|
||||
);
|
||||
for (const config of toolset.configs ?? []) {
|
||||
if (config.enabled) {
|
||||
enabled.add(config.name);
|
||||
} else {
|
||||
enabled.delete(config.name);
|
||||
}
|
||||
}
|
||||
return [...enabled].sort().join(",");
|
||||
};
|
||||
@@ -19,8 +19,8 @@ Autumn:
|
||||
${autumnMcpInstructions}
|
||||
|
||||
Writes and approvals (overrides the MCP/billing approval steps above):
|
||||
- Calling a destructive write tool auto-pauses for an approval card; that is the only gate, and it shows only when you call the write tool. Ignore the billing "ask first / wait for yes" steps — they're for direct API clients.
|
||||
- Never ask permission to preview. With enough info: preview, then same-turn state the one-line billing impact and call the matching write tool with the previewed args — no plain-text approval, no waiting for "yes".
|
||||
- The billing resource's "obtain approval" step IS calling the write tool: it auto-pauses for an approval card — the only gate, shown only when you call the tool.
|
||||
- Never ask permission to preview, and never end your turn after a preview. With enough info, in ONE turn: (1) call the preview tool, (2) state the one-line billing impact, (3) immediately call the matching write tool with the previewed args. No prose "yes", no waiting.
|
||||
|
||||
Web search:
|
||||
- Use web search only for current or external web context.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { MessageParams } from "../../agent/runMessage/types.js";
|
||||
import type { AutumnOrgContext } from "../../internal/autumnMcp/orgContextService.js";
|
||||
import { replyStyleInstructions } from "./instructions/index.js";
|
||||
|
||||
export const buildHarnessMessageText = ({
|
||||
env,
|
||||
@@ -14,7 +13,6 @@ export const buildHarnessMessageText = ({
|
||||
params: MessageParams;
|
||||
}) => {
|
||||
const preamble = [
|
||||
`Reply style:\n${replyStyleInstructions}`,
|
||||
`Current Autumn environment: ${env}. This Slack thread is locked to this environment; if the user asks to switch environments, tell them to start a new thread.`,
|
||||
orgContext?.text
|
||||
? `Org context:\nTreat these JSON blocks as already-run Autumn tool results. Do not call getAgentRules, listPlans, or listFeatures again unless the needed record is absent or the user asks to refresh. Use listFeatures to interpret feature ids, names, and types.\n${orgContext.text}`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// export const DEFAULT_CHAT_MODEL = "anthropic/claude-sonnet-4-6";
|
||||
export const DEFAULT_CHAT_MODEL = "anthropic/claude-opus-4-6";
|
||||
|
||||
// Cheap/fast model for the throwaway env classifier (sandbox vs live) — it doesn't
|
||||
|
||||
@@ -89,6 +89,8 @@ export type BotMessage = {
|
||||
onApprovalsSuperseded?: (approvals: ChatApproval[]) => Promise<void> | void;
|
||||
/** Fires once the managed agent is ready to run its first turn (startup done). */
|
||||
onAgentReady?: () => Promise<void> | void;
|
||||
/** Fires when the agent starts an inference or emits thinking — drives the live status. */
|
||||
onThinking?: () => void;
|
||||
onTurnComplete?: (text: string) => Promise<void> | void;
|
||||
providerUserId: string;
|
||||
run?: ActiveRun;
|
||||
|
||||
@@ -410,6 +410,9 @@ const modifierPhrases = (toolArgs?: Record<string, unknown>) => {
|
||||
: enableImmediately === false
|
||||
? "access waits for payment"
|
||||
: null,
|
||||
getString(request.plan_schedule)
|
||||
? `plan schedule: ${request.plan_schedule}`
|
||||
: null,
|
||||
getString(request.proration_behavior)
|
||||
? `proration: ${request.proration_behavior}`
|
||||
: null,
|
||||
|
||||
75
apps/leaf/src/ui/statusTicker.ts
Normal file
75
apps/leaf/src/ui/statusTicker.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { ReplyTarget } from "./progress.js";
|
||||
|
||||
// Generic "still working" verbs cycled during model inference, when there's no
|
||||
// concrete tool action to show. Slack renders its own shimmer; we just keep the
|
||||
// text changing so the wait feels alive.
|
||||
const THINKING_VERBS = [
|
||||
"Thinking",
|
||||
"Analyzing",
|
||||
"Reasoning",
|
||||
"Pondering",
|
||||
"Computing",
|
||||
"Untangling",
|
||||
"Synthesizing",
|
||||
"Discombobulating",
|
||||
"Ruminating",
|
||||
"Crunching",
|
||||
];
|
||||
|
||||
// Slow enough to stay well under Slack's status rate limit, fast enough to read
|
||||
// as motion.
|
||||
const VERB_CYCLE_MS = 6000;
|
||||
|
||||
export type StatusTicker = {
|
||||
/** Agent is mid-inference with nothing concrete to show — cycle generic verbs. */
|
||||
thinking: () => void;
|
||||
/** A tool/action ran — pin its label until the next inference. */
|
||||
activity: (message: string) => void;
|
||||
/** Tear down the interval; further updates are ignored. */
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export const createStatusTicker = (target: ReplyTarget): StatusTicker => {
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let verbIndex = 0;
|
||||
let stopped = false;
|
||||
let current = "";
|
||||
|
||||
const render = (text: string) => {
|
||||
if (stopped || text === current) return;
|
||||
current = text;
|
||||
target.startTyping(text).catch((error) => {
|
||||
console.warn("[chat] Could not update status", error);
|
||||
});
|
||||
};
|
||||
|
||||
const stopCycling = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startCycling = () => {
|
||||
if (timer || stopped) return;
|
||||
timer = setInterval(() => {
|
||||
verbIndex = (verbIndex + 1) % THINKING_VERBS.length;
|
||||
render(`${THINKING_VERBS[verbIndex]}…`);
|
||||
}, VERB_CYCLE_MS);
|
||||
};
|
||||
|
||||
return {
|
||||
thinking: () => {
|
||||
render(`${THINKING_VERBS[verbIndex]}…`);
|
||||
startCycling();
|
||||
},
|
||||
activity: (message: string) => {
|
||||
stopCycling();
|
||||
render(`${message}…`);
|
||||
},
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
stopCycling();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,7 @@
|
||||
// Tests the agent's ability to diff a requested boolean-feature list against what
|
||||
// the Scale plan already grants. Scale already includes priority_queue, approval_chains,
|
||||
// and compliance_controls, so those must be no-ops — only the two features missing from
|
||||
// the plan (hosted_solution, unlimited_seats) belong in add_items, and the two the user
|
||||
// excludes (revision_history, brand_controls) belong in remove_items. The array matcher
|
||||
// is exact-length, so re-adding an already-present feature fails the case.
|
||||
// the Scale plan already grants, treating the list as the exhaustive set: features
|
||||
// on the plan but not listed must be removed, listed features missing from the plan
|
||||
// must be added, and listed features already on the plan are no-ops.
|
||||
import { withCustomers } from "../../fixtures/createSetup.js";
|
||||
import { billing, response, tools } from "../../fixtures/expectations/index.js";
|
||||
import { orgSetups } from "../../fixtures/orgSetups.js";
|
||||
@@ -14,6 +12,13 @@ import {
|
||||
user,
|
||||
} from "../../harness/index.js";
|
||||
|
||||
const userPrompt = `attach the Scale plan to kp-customer-0099. They get:
|
||||
- priority queue
|
||||
- approval chains
|
||||
- compliance controls
|
||||
- hosted solution
|
||||
- unlimited seats`;
|
||||
|
||||
type EvalMetadata = {
|
||||
domain: "billing";
|
||||
flow: "attach";
|
||||
@@ -26,7 +31,7 @@ const setup = withCustomers({
|
||||
customers: ({ customers }) => ({
|
||||
meridian: customers.base({
|
||||
email: "billing@meridiandata.example",
|
||||
id: "kp-customer-0100",
|
||||
id: "kp-customer-0099",
|
||||
name: "Meridian Data",
|
||||
}),
|
||||
}),
|
||||
@@ -34,27 +39,35 @@ const setup = withCustomers({
|
||||
workspace: entities.base({
|
||||
customer: customers.meridian,
|
||||
feature: features.workspaces,
|
||||
id: "kp-customer-0100-main",
|
||||
id: "kp-customer-0099-main",
|
||||
name: "Main",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Only the two features Scale lacks are added; the two excluded are removed.
|
||||
// Anything already on the plan (priority_queue, approval_chains, compliance_controls)
|
||||
// must NOT appear here.
|
||||
// Add the two listed features Scale lacks; remove the nine Scale booleans the
|
||||
// list omits. The three listed features already on Scale (priority_queue,
|
||||
// approval_chains, compliance_controls) stay untouched.
|
||||
const features = setup.refs.features;
|
||||
const expectedAttachRequest = {
|
||||
customer_id: setup.refs.customers.meridian.id,
|
||||
entity_id: setup.refs.entities.workspace.id,
|
||||
plan_id: setup.refs.plans.scale.id,
|
||||
customize: {
|
||||
add_items: [
|
||||
{ feature_id: setup.refs.features.hosted_solution.id, unlimited: true },
|
||||
{ feature_id: setup.refs.features.unlimited_seats.id, unlimited: true },
|
||||
{ feature_id: features.hosted_solution.id, unlimited: true },
|
||||
{ feature_id: features.unlimited_seats.id, unlimited: true },
|
||||
],
|
||||
remove_items: [
|
||||
{ feature_id: setup.refs.features.revision_history.id },
|
||||
{ feature_id: setup.refs.features.brand_controls.id },
|
||||
{ feature_id: features.insight_reports.id },
|
||||
{ feature_id: features.team_policies.id },
|
||||
{ feature_id: features.private_spaces.id },
|
||||
{ feature_id: features.export_center.id },
|
||||
{ feature_id: features.automation_rules.id },
|
||||
{ feature_id: features.platform_api.id },
|
||||
{ feature_id: features.outbound_hooks.id },
|
||||
{ feature_id: features.brand_controls.id },
|
||||
{ feature_id: features.revision_history.id },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -70,17 +83,9 @@ initEval<EvalMetadata>({
|
||||
timeout: 150_000,
|
||||
cases: [
|
||||
{
|
||||
name: "adds only missing features and removes excluded ones",
|
||||
name: "diffs the listed set against the plan: adds missing, removes extras",
|
||||
conversation: [
|
||||
user({
|
||||
message: `attach the Scale plan to kp-customer-0099. They get:
|
||||
- priority queue
|
||||
- approval chains
|
||||
- compliance controls
|
||||
- hosted solution
|
||||
- unlimited seats
|
||||
`,
|
||||
}),
|
||||
user({ message: userPrompt }),
|
||||
user({ message: "Looks good, attach it." }),
|
||||
approve({ optional: false }),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Tests how the agent handles a feature list that mixes real and unavailable
|
||||
// features. Three listed features exist on this org (priority queue, approval
|
||||
// chains, compliance controls) but one does not (AI answer assistant), so the agent
|
||||
// must apply the valid ones while recognizing the unavailable one rather than
|
||||
// inventing a feature_id for it. Expectations are intentionally omitted for now.
|
||||
import { withCustomers } from "../../fixtures/createSetup.js";
|
||||
import { orgSetups } from "../../fixtures/orgSetups.js";
|
||||
import {
|
||||
approve,
|
||||
createClaudeManagedLiveDriver,
|
||||
initEval,
|
||||
user,
|
||||
} from "../../harness/index.js";
|
||||
|
||||
const userPrompt = `attach the Scale plan to kp-customer-0100. On top of the plan they should also get:
|
||||
- priority queue
|
||||
- approval chains
|
||||
- compliance controls
|
||||
- AI answer assistant`;
|
||||
|
||||
type EvalMetadata = {
|
||||
domain: "billing";
|
||||
flow: "attach";
|
||||
};
|
||||
|
||||
const experimentName = "nonexistent-features";
|
||||
|
||||
const setup = withCustomers({
|
||||
setup: orgSetups.knowledgePlatform(),
|
||||
customers: ({ customers }) => ({
|
||||
meridian: customers.base({
|
||||
email: "billing@meridiandata.example",
|
||||
id: "kp-customer-0100",
|
||||
name: "Meridian Data",
|
||||
}),
|
||||
}),
|
||||
entities: ({ customers, entities, features }) => ({
|
||||
workspace: entities.base({
|
||||
customer: customers.meridian,
|
||||
feature: features.workspaces,
|
||||
id: "kp-customer-0100-main",
|
||||
name: "Main",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
initEval<EvalMetadata>({
|
||||
experimentName,
|
||||
setup,
|
||||
driver: createClaudeManagedLiveDriver(),
|
||||
metadata: {
|
||||
domain: "billing",
|
||||
flow: "attach",
|
||||
},
|
||||
timeout: 150_000,
|
||||
cases: [
|
||||
{
|
||||
name: "handles a request for features not in the org catalog",
|
||||
conversation: [
|
||||
user({ message: userPrompt }),
|
||||
user({ message: "Go ahead with whatever you can." }),
|
||||
approve({ optional: true }),
|
||||
],
|
||||
expect: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Sales-led checkout: the user asks to attach Scale and hand back a checkout
|
||||
// session URL. The attach must force redirect_mode "always" so a checkout URL is
|
||||
// returned (and skip invoice mode); the plan activates once the customer pays.
|
||||
import { withCustomers } from "../../fixtures/createSetup.js";
|
||||
import {
|
||||
api,
|
||||
billing,
|
||||
response,
|
||||
tools,
|
||||
} from "../../fixtures/expectations/index.js";
|
||||
import { orgSetups } from "../../fixtures/orgSetups.js";
|
||||
import {
|
||||
approve,
|
||||
createClaudeManagedLiveDriver,
|
||||
initEval,
|
||||
user,
|
||||
} from "../../harness/index.js";
|
||||
|
||||
type EvalMetadata = {
|
||||
domain: "billing";
|
||||
flow: "attach";
|
||||
};
|
||||
|
||||
const experimentName = "checkout-session";
|
||||
|
||||
const setup = withCustomers({
|
||||
setup: orgSetups.knowledgePlatform(),
|
||||
customers: ({ customers }) => ({
|
||||
atlas: customers.base({
|
||||
email: "billing@atlasrobotics.example",
|
||||
id: "kp-customer-0003",
|
||||
name: "Atlas Robotics",
|
||||
}),
|
||||
}),
|
||||
entities: ({ customers, entities, features }) => ({
|
||||
workspace: entities.base({
|
||||
customer: customers.atlas,
|
||||
feature: features.workspaces,
|
||||
id: "kp-customer-0003-main",
|
||||
name: "Main",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const expectedAttachRequest = {
|
||||
customer_id: setup.refs.customers.atlas.id,
|
||||
entity_id: setup.refs.entities.workspace.id,
|
||||
plan_id: setup.refs.plans.scale.id,
|
||||
redirect_mode: "always",
|
||||
};
|
||||
|
||||
initEval<EvalMetadata>({
|
||||
experimentName,
|
||||
setup,
|
||||
driver: createClaudeManagedLiveDriver(),
|
||||
metadata: {
|
||||
domain: "billing",
|
||||
flow: "attach",
|
||||
},
|
||||
timeout: 120_000,
|
||||
cases: [
|
||||
{
|
||||
name: "attach Scale and return a checkout session URL",
|
||||
conversation: [
|
||||
user({
|
||||
message:
|
||||
"attach the Scale plan to kp-customer-0003. And generate a checkout session URL",
|
||||
}),
|
||||
user({ message: "Looks good, go ahead." }),
|
||||
approve({ optional: false }),
|
||||
],
|
||||
expect: [
|
||||
tools.called({
|
||||
toolNames: ["getAgentRules", "listPlans", "previewAttach", "attach"],
|
||||
}),
|
||||
...billing.previewThenWrite({
|
||||
body: expectedAttachRequest,
|
||||
write: "attach",
|
||||
}),
|
||||
api.bodyExcludes({ fields: ["invoice_mode"], toolName: "attach" }),
|
||||
response.mentions({
|
||||
phrases: ["Scale", "checkout.example.com/cs_kp-customer-0003"],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -39,7 +39,7 @@ const createEvalMcpServer = () =>
|
||||
version: "0.0.1",
|
||||
description: "Operate on Autumn customers, plans, and billing.",
|
||||
instructions:
|
||||
"Use preview tools before billing writes. Write tools are destructive and should only be called after explicit user confirmation.",
|
||||
"Use preview tools before billing writes. Write tools are destructive; obtain approval via your client's approval mechanism before calling one.",
|
||||
resources: evalResources,
|
||||
tools: createRawAutumnOperationTools(),
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
"vite:build": "bun -F @autumn/vite build:bun",
|
||||
"t": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/testScripts/testDispatcher.ts",
|
||||
"cm": "cd server && bun cm",
|
||||
"kp": "cd server && bun kp",
|
||||
"scenario": "cd server && bun scenario",
|
||||
"dw": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw/index.ts",
|
||||
"dw:teardown": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw/index.ts teardown",
|
||||
"dw:list": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw/index.ts list",
|
||||
|
||||
@@ -33,15 +33,15 @@ Read `autumn://docs/concepts` to understand Autumn's model: Customer, Entity, Pl
|
||||
- If one ambiguity changes which of the other questions apply, resolve it first on its own before gathering the rest.
|
||||
- Gather all remaining missing questions from the checklist and ask them together.
|
||||
- If there are no missing questions, call the preview tool.
|
||||
- Surface the preview and ask for the user's feedback or approval.
|
||||
- If the user changes anything, update params and repeat from the relevant checklist step.
|
||||
- If the user approves the preview, execute the exact previewed billing action.
|
||||
- Surface the preview's immediate billing impact, then obtain approval via your client's approval mechanism.
|
||||
- If params change, update them and repeat from the relevant checklist step.
|
||||
- Once approved, apply the exact previewed billing action.
|
||||
|
||||
</workflow>
|
||||
|
||||
<rules>
|
||||
|
||||
- **APPROVAL MUST BE GRANTED BEFORE PERFORMING ANY MUTATING BILLING ACTION.**
|
||||
- **A mutating billing action requires approval before it takes effect — obtain it via your client's approval mechanism.**
|
||||
- Don't propose or promise steps outside what your tools can do. If the goal isn't reachable, say so plainly rather than inventing a workaround.
|
||||
- Read this full resource before billing work and follow sections in order; later sections can define params that must be resolved before previewing.
|
||||
- Monetary amounts are major currency units: `$1,150` -> `1150`, not `115000`.
|
||||
@@ -49,6 +49,10 @@ Read `autumn://docs/concepts` to understand Autumn's model: Customer, Entity, Pl
|
||||
- If using `invoice_mode` and the customer has no email, ask for the email and call `updateCustomer` before previewing.
|
||||
- Ask independent missing questions together in one concise message, using one bullet point per question.
|
||||
- While gathering params, ask only for values needed to build the billing request; do not explain plan internals unless the user asks.
|
||||
- If a customization is inferred, surface it for confirmation before previewing or writing. If its intent is ambiguous, ask before building — don't resolve it silently. When surfacing a customization, describe it as a patch (what was added/removed/changed vs the catalog plan), not a full restatement of every feature.
|
||||
- If the user gives an included credit/feature amount and the plan has a prepaid item for that feature, clarify whether they mean the quantity or a customization of the item, unless it's clear.
|
||||
- Before any trial action, re-read the Trials section in `autumn://docs/concepts`.
|
||||
- Adding a trial for a customer who already has a paid subscription resets the Stripe billing cycle; warn the user and offer the `on_end: "revert"` flow, then let them choose.
|
||||
|
||||
</rules>
|
||||
|
||||
@@ -100,7 +104,7 @@ Read `autumn://docs/concepts` to understand Autumn's model: Customer, Entity, Pl
|
||||
- Preview only after action, target IDs, quantities, customization, timing, and billing behavior are known or intentionally defaulted.
|
||||
- If missing information could change immediate charges, access timing, or scheduled state, ask before previewing.
|
||||
- The main purpose of preview is to determine immediate billing impact: `total`, `currency`, and `line_items`.
|
||||
- Summarize the preview before asking for confirmation.
|
||||
- Summarize the preview's impact before the write.
|
||||
- Lead with immediate impact: amount due now, no immediate charge, or credit.
|
||||
- Include only preview facts that affect approval; avoid repeating context the user already resolved.
|
||||
- If `next_cycle` exists, explain the next event: date, amount, and likely reason such as renewal, trial end, cancellation, downgrade, phase change, or nearest multi-interval event.
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
- Use the `customize` object for customer-specific plan terms.
|
||||
- Base price changes go in `customize.price`; e.g. if the user says Pro is $50/month but the catalog Pro plan is $20/month, customize the price.
|
||||
- A bare number with an interval but no `$` and no unit (e.g. "1k/yr", "2k/mo") is ambiguous between `customize.price` and a feature quantity (credits/seats); clarify which before building the customize, and read the same pattern consistently across the request.
|
||||
- Plan item changes can be PUT-style or PATCH-style.
|
||||
- PUT-style: `customize.items` replaces the full item set.
|
||||
- PATCH-style: `customize.add_items` and `customize.remove_items` change selected items.
|
||||
- A list of what a customer "gets" is ambiguous: restating the plan, adding on top, or the exact set (items not listed are removed/zeroed). If the reading changes what they receive vs the catalog plan, ask which before building.
|
||||
- "Features" may mean only some items (e.g. booleans) or include credits/metered items; clarify scope before removing anything priced.
|
||||
- Plan item changes are always PATCH-style: `customize.add_items` and `customize.remove_items` change selected items.
|
||||
- Never use `customize.items` (PUT-style full replacement) or `update_items`. To make the plan's items the exact set, remove the unwanted ones with `remove_items` and add the missing ones with `add_items`.
|
||||
- Each `remove_items` entry is a filter for items to remove from the plan.
|
||||
- Include `billing_method`, `interval`, or `interval_count` in the filter when `feature_id` alone could match multiple items.
|
||||
- Prefer PATCH-style. Use PUT-style only when the user intends to replace the entire plan item set.
|
||||
- Do not use `update_items`.
|
||||
- If any customization is inferred, surface it to the user for confirmation before previewing or writing.
|
||||
- Replace an item's configuration: remove the old item and add the new version in the same PATCH-style `customize`.
|
||||
- If a plan name/id/context suggests an Enterprise or custom placeholder plan and the plan has no base price, and no commercial terms were specified, ask the user whether they want to customize the base price.
|
||||
|
||||
@@ -21,7 +19,7 @@ Use cases:
|
||||
{
|
||||
"customer_id": "cus_123",
|
||||
"plan_id": "pro",
|
||||
"customize": { "add_items": [{ "feature_id": "sso", "unlimited": true }] }
|
||||
"customize": { "add_items": [{ "feature_id": "sso" }] }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -65,7 +63,7 @@ Examples:
|
||||
|
||||
- Add a boolean feature:
|
||||
```json
|
||||
{ "customize": { "add_items": [{ "feature_id": "sso", "unlimited": true }] } }
|
||||
{ "customize": { "add_items": [{ "feature_id": "sso" }] } }
|
||||
```
|
||||
|
||||
- Remove a feature:
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
<attach-timing>
|
||||
|
||||
- If `plan_schedule` is omitted, downgrades are usually scheduled for end of cycle and the current plan transitions out then.
|
||||
- Use `plan_schedule: "immediate"` when the user wants an immediate downgrade.
|
||||
- To attach now, explicitly set `plan_schedule: "immediate"`; omitting it can schedule a lower- or zero-base-price plan for end of cycle.
|
||||
- A downgrade (incoming base price genuinely lower than the current plan's) should be flagged to the user, asking whether to schedule it for end of cycle. A no-base-price plan (e.g. Enterprise/custom, priced per customer) is not a downgrade.
|
||||
- Use `starts_at` for single-plan backdates or future starts; do not use `createSchedule` just for this.
|
||||
- Backdating is only allowed when the customer has no existing Stripe subscription. If the API rejects it, explain that constraint.
|
||||
- For future billing start with immediate access, set future `starts_at` and `enable_plan_immediately: true`; otherwise the user's plan is created with `scheduled` status in Autumn and access starts on the specified `starts_at`.
|
||||
|
||||
@@ -29,9 +29,8 @@
|
||||
|
||||
<boolean>
|
||||
|
||||
- Pass `feature_id`.
|
||||
- Pass only `feature_id`; set neither `included` nor `unlimited`. `feature_id` alone grants access.
|
||||
- Grants access rather than quantity.
|
||||
- Boolean/unlimited feature grants use `unlimited: true`, not `included: 1`.
|
||||
- Boolean plan items cannot be paid today; charge through `Plan.price` or another metered feature instead.
|
||||
|
||||
</boolean>
|
||||
|
||||
@@ -56,10 +56,9 @@
|
||||
|
||||
<trial-behavior>
|
||||
|
||||
- This covers how to MODEL trials in the catalog. For how to put a customer on a trial at attach time (card-required, no-card, revert), see the Trials concept.
|
||||
- For card-required trials, put `free_trial` on the real paid plan.
|
||||
- Stripe creates a trialing subscription and charges when the trial ends.
|
||||
- For no-card trials, prefer a separate limited-time trial plan, e.g. `pro_trial`, plus the real paid `pro`.
|
||||
- The limited-time trial plan gives temporary access, expires automatically, and lets the user later enter the normal checkout flow for `pro`.
|
||||
- For no-card trials, prefer a separate limited-time trial plan, e.g. `pro_trial`, plus the real paid `pro` — it gives temporary access, expires automatically, and lets the user later enter the normal checkout flow for `pro`.
|
||||
|
||||
</trial-behavior>
|
||||
|
||||
|
||||
28
packages/mcp/src/resources-v2/concepts/trials.md
Normal file
28
packages/mcp/src/resources-v2/concepts/trials.md
Normal file
@@ -0,0 +1,28 @@
|
||||
### Trials
|
||||
|
||||
<intro>
|
||||
|
||||
- A trial gives a customer temporary access to a plan before billing begins.
|
||||
- Set a trial with `free_trial` on attach: `{ duration_length, duration_type (day|month|year), card_required, on_end }`.
|
||||
- `on_end`: `bill` charges when the trial ends (default); `revert` expires the trial and restores the customer's previous plan.
|
||||
|
||||
</intro>
|
||||
|
||||
<no-existing-plan>
|
||||
|
||||
- The customer is on no paid plan. Three flows:
|
||||
- Card-required trial (preferred, default): attach with `free_trial` and `card_required: true`. If the customer has no payment method, the attach returns a checkout URL (or an invoice URL when `invoice_mode.enabled`) to collect a card; they are charged when the trial ends.
|
||||
- No-card trial: attach with `card_required: false`. The subscription starts with no card and ends at trial end if none is added. While on it, the customer cannot upgrade or attach another plan until they add a card via the Stripe billing portal.
|
||||
- Limited-time trial plan: a separate free, no-card plan in the catalog (e.g. `pro_trial`) that grants temporary access, expires automatically, then routes the customer into the normal checkout for the real plan. See `<trial-behavior>` in the Plan concept for modeling. Some orgs configure this — recognize and use it when present.
|
||||
- Default to `card_required: true` unless the user explicitly asks for no card.
|
||||
|
||||
</no-existing-plan>
|
||||
|
||||
<existing-paid-plan>
|
||||
|
||||
- The customer already has an active (Stripe) subscription — common in sales-led trials.
|
||||
- Regular flow: attaching a plan with a trial (or updating the subscription to add one) resets the Stripe billing anchor/cycle. This can be undesired so should be carefully treated.
|
||||
- Revert flow: attach the new plan with `on_end: "revert"` (and `card_required: false`). This grants the plan in Autumn without touching the Stripe subscription; at trial end Autumn moves the customer back to their original plan, preserving the existing billing cycle.
|
||||
- Set `plan_schedule: "immediate"` on the revert-flow attach so the trial starts now; without it a no-base-price plan over a paid sub is scheduled for end of cycle.
|
||||
|
||||
</existing-paid-plan>
|
||||
@@ -34,6 +34,7 @@ const conceptResource = {
|
||||
"./concepts/feature.md",
|
||||
"./concepts/plan.md",
|
||||
"./concepts/plan-item.md",
|
||||
"./concepts/trials.md",
|
||||
"./concepts/customer-entity.md",
|
||||
"./concepts/billing-controls.md",
|
||||
],
|
||||
@@ -177,7 +178,12 @@ export const createAutumnMcpResources = ({
|
||||
baseUrl: string | URL;
|
||||
}): MCPServerResources => {
|
||||
let docs: AutumnMcpResourceDoc[] | undefined;
|
||||
// Re-read resource markdown from disk every call in dev so prompt edits take
|
||||
// effect without a restart; memoize in prod.
|
||||
const getDocs = () => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return compileResources({ baseUrl });
|
||||
}
|
||||
docs ??= compileResources({ baseUrl });
|
||||
return docs;
|
||||
};
|
||||
|
||||
@@ -26,5 +26,5 @@ Always read the relevant Autumn MCP resources to understand Autumn before starti
|
||||
## Writes
|
||||
|
||||
- Use preview tools before billing writes.
|
||||
- Write tools are destructive; get explicit user approval before calling one.
|
||||
- Write tools are destructive; obtain approval via your client's approval mechanism before calling one.
|
||||
- If a preview fails, state the blocking reason once and stop; do not call or suggest the write tool.
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"parallel-tests:debug": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/testRunner/runParallelGroups.ts --debug",
|
||||
"clear-master": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/clearMasterOrg.ts",
|
||||
"cm": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/clearMaster.ts",
|
||||
"kp": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/scenarios/agent/knowledge-platform.ts",
|
||||
"scenario": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/scenarios/agent/scenario.ts",
|
||||
"ts": "bunx tsgo --build --noEmit",
|
||||
"test:unit": "ENV_FILE=.env infisical run --env=dev --recursive -- bun test --isolate tests/unit",
|
||||
"test:integration": "ENV_FILE=.env infisical run --env=dev --recursive -- bun test --timeout 0 --preload ./tests/setup-integration-tests.ts",
|
||||
|
||||
@@ -4,27 +4,23 @@ import {
|
||||
RecaseError,
|
||||
} from "@autumn/shared";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { isDeferredInvoiceMode } from "@/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode";
|
||||
|
||||
/**
|
||||
* Validates invoice mode configuration against the attach context.
|
||||
*
|
||||
* Throws error when:
|
||||
* - Invoice mode with deferred activation (enableProductImmediately=false) is used for a downgrade
|
||||
* (planTiming="end_of_cycle"). Downgrades are scheduled for end of cycle and have no immediate
|
||||
* invoice to pay, so deferred activation makes no sense.
|
||||
* Throws when deferred invoice-mode activation is used for a downgrade
|
||||
* (planTiming="end_of_cycle"): there is no immediate invoice to pay, so deferral makes no sense.
|
||||
*/
|
||||
export const handleAttachInvoiceModeErrors = ({
|
||||
billingContext,
|
||||
}: {
|
||||
billingContext: AttachBillingContext;
|
||||
}) => {
|
||||
const { invoiceMode, planTiming } = billingContext;
|
||||
const { planTiming } = billingContext;
|
||||
|
||||
// Check: Invoice mode deferred + downgrade (scheduled plan)
|
||||
if (
|
||||
invoiceMode?.enableProductImmediately === false &&
|
||||
planTiming === "end_of_cycle"
|
||||
) {
|
||||
if (isDeferredInvoiceMode({ billingContext }) && planTiming === "end_of_cycle") {
|
||||
throw new RecaseError({
|
||||
message:
|
||||
"Cannot use invoice mode with deferred activation for downgrades. Downgrades are scheduled for end of cycle and have no immediate invoice to pay.",
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
orgDisableStripeWrites,
|
||||
orgToReturnUrl,
|
||||
} from "@autumn/shared";
|
||||
import { all } from "better-all";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
|
||||
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
|
||||
@@ -53,30 +52,25 @@ export const setupAttachBillingContext = async ({
|
||||
}): Promise<AttachBillingContext> => {
|
||||
const { fullCustomer: fullCustomerOverride } = contextOverride;
|
||||
|
||||
const {
|
||||
fullCustomer,
|
||||
attachProductContext: {
|
||||
fullProduct: attachProduct,
|
||||
customPrices,
|
||||
customEnts,
|
||||
},
|
||||
} = await all({
|
||||
async fullCustomer() {
|
||||
return (
|
||||
// fullCustomer must resolve before the product context so patch-style customize
|
||||
// (add_items/remove_items) routes through setupAttachPatchProductContext, matching
|
||||
// multiAttach (setupImmediateMultiProductBillingContext) and createSchedule.
|
||||
const fullCustomer =
|
||||
fullCustomerOverride ??
|
||||
(await setupFullCustomerContext({
|
||||
ctx,
|
||||
params,
|
||||
}))
|
||||
);
|
||||
},
|
||||
async attachProductContext() {
|
||||
return setupAttachProductContext({
|
||||
}));
|
||||
|
||||
const {
|
||||
fullProduct: attachProduct,
|
||||
customPrices,
|
||||
customEnts,
|
||||
} = await setupAttachProductContext({
|
||||
ctx,
|
||||
params,
|
||||
contextOverride,
|
||||
});
|
||||
},
|
||||
fullCustomer,
|
||||
});
|
||||
|
||||
const { currentCustomerProduct, scheduledCustomerProduct, planTiming } =
|
||||
|
||||
@@ -7,6 +7,9 @@ export const isDeferredInvoiceMode = ({
|
||||
}): boolean => {
|
||||
const isInvoiceMode = Boolean(billingContext.invoiceMode);
|
||||
|
||||
// Top-level enable_plan_immediately is authoritative over invoice_mode's nested flag.
|
||||
if (billingContext.enablePlanImmediately === true) return false;
|
||||
|
||||
const shouldDefer =
|
||||
billingContext.invoiceMode?.enableProductImmediately === false;
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Regression: attach with PATCH-style customize (add_items / remove_items).
|
||||
*
|
||||
* Red-failure mode (pre-fix):
|
||||
* - setupAttachBillingContext called setupAttachProductContext WITHOUT fullCustomer,
|
||||
* so the patch pipeline (setupAttachPatchProductContext -> setupPatchContext) was
|
||||
* never reached. Patch-style customize fell back to customizePlanV1ToV0, which only
|
||||
* understands PUT-style {price, items} — it dropped EVERY feature item, leaving the
|
||||
* customer on the plan's base price with no entitlements at all.
|
||||
*
|
||||
* Green-success criteria (post-fix):
|
||||
* - Base items are retained, remove_items are dropped, add_items are present, and
|
||||
* feature_quantities apply — matching multiAttach / createSchedule patch behavior.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV5,
|
||||
type AttachParamsV1Input,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
expectCustomerProducts,
|
||||
expectProductActive,
|
||||
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
|
||||
import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("attach patch customize: add_items/remove_items keep base items")}`, async () => {
|
||||
const customerId = "attach-patch-customize-basic";
|
||||
|
||||
// Mirrors the reported payload: base price + several feature items, a boolean
|
||||
// flag to remove, and a prepaid item whose quantity comes from feature_quantities.
|
||||
const scale = products.base({
|
||||
id: "scale",
|
||||
items: [
|
||||
items.monthlyPrice({ price: 500 }),
|
||||
items.monthlyMessages({ includedUsage: 100 }), // retained canary
|
||||
items.adminRights(), // boolean flag to remove
|
||||
items.prepaidUsers({ billingUnits: 1 }), // quantity via feature_quantities
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV2_2 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [scale] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>({
|
||||
customer_id: customerId,
|
||||
plan_id: scale.id,
|
||||
customize: {
|
||||
add_items: [itemsV2.dashboard()],
|
||||
remove_items: [{ feature_id: TestFeature.AdminRights }],
|
||||
},
|
||||
invoice_mode: { enabled: true, finalize: false },
|
||||
feature_quantities: [{ feature_id: TestFeature.Users, quantity: 3 }],
|
||||
enable_plan_immediately: true,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
|
||||
// Plan active immediately (enable_plan_immediately overrides invoice-mode deferral).
|
||||
await expectProductActive({ customer, productId: scale.id });
|
||||
await expectCustomerProducts({ customer, active: [scale.id] });
|
||||
|
||||
// Base feature item retained (the bug dropped this entirely).
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 100,
|
||||
usage: 0,
|
||||
planId: scale.id,
|
||||
});
|
||||
|
||||
// add_items applied.
|
||||
expectFlagCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Dashboard,
|
||||
planId: scale.id,
|
||||
});
|
||||
|
||||
// remove_items applied.
|
||||
expect(customer.flags?.[TestFeature.AdminRights]).toBeUndefined();
|
||||
|
||||
// feature_quantities applied to the prepaid item.
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Users,
|
||||
remaining: 3,
|
||||
usage: 0,
|
||||
planId: scale.id,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Regression: attach PATCH-style customize (remove + add) can reshape an item's
|
||||
* full config — included amount, price/tiers, and billing behavior (billing_method).
|
||||
*
|
||||
* Green-success criteria (post-fix):
|
||||
* - remove_items + add_items replaces a plan item, applying the new included usage,
|
||||
* the new price/tiers (reflected in the invoice total), and the new billing_method.
|
||||
*/
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
type ApiCustomerV5,
|
||||
type AttachParamsV1Input,
|
||||
BillingInterval,
|
||||
BillingMethod,
|
||||
ResetInterval,
|
||||
TierInfinite,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
expectCustomerProducts,
|
||||
expectProductActive,
|
||||
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import chalk from "chalk";
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("attach patch config: customize included credits via remove/add")}`, async () => {
|
||||
const customerId = "attach-patch-config-credits";
|
||||
|
||||
const scale = products.base({
|
||||
id: "scale",
|
||||
items: [
|
||||
items.monthlyPrice({ price: 20 }),
|
||||
items.monthlyCredits({ includedUsage: 100 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV2_2 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [scale] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const attachParams: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: scale.id,
|
||||
customize: {
|
||||
remove_items: [{ feature_id: TestFeature.Credits }],
|
||||
add_items: [
|
||||
{
|
||||
feature_id: TestFeature.Credits,
|
||||
included: 500,
|
||||
reset: { interval: ResetInterval.Month },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Included amount change only — base price unchanged.
|
||||
const preview =
|
||||
await autumnV2_2.billing.previewAttach<AttachParamsV1Input>(attachParams);
|
||||
expect(preview.total).toBe(20);
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(attachParams);
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
await expectProductActive({ customer, productId: scale.id });
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Credits,
|
||||
remaining: 500,
|
||||
usage: 0,
|
||||
planId: scale.id,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("attach patch config: customize price/tiers via remove/add")}`, async () => {
|
||||
const customerId = "attach-patch-config-tiers";
|
||||
|
||||
const scale = products.base({
|
||||
id: "scale",
|
||||
items: [
|
||||
items.monthlyPrice({ price: 20 }),
|
||||
items.prepaidMessages({ includedUsage: 0, billingUnits: 100, price: 10 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV2_2 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [scale] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Replace the flat $10/100 prepaid with a graduated tiered price.
|
||||
const attachParams: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: scale.id,
|
||||
customize: {
|
||||
remove_items: [{ feature_id: TestFeature.Messages }],
|
||||
add_items: [
|
||||
itemsV2.tieredPrepaidMessages({
|
||||
included: 0,
|
||||
billingUnits: 100,
|
||||
tiers: [
|
||||
{ to: 300, amount: 6 },
|
||||
{ to: TierInfinite, amount: 3 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
feature_quantities: [{ feature_id: TestFeature.Messages, quantity: 500 }],
|
||||
};
|
||||
|
||||
// 5 packs of 100: 3 packs @ $6 ($18) + 2 packs @ $3 ($6) = $24, + $20 base = $44.
|
||||
const preview =
|
||||
await autumnV2_2.billing.previewAttach<AttachParamsV1Input>(attachParams);
|
||||
expect(preview.total).toBe(44);
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(attachParams);
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
await expectProductActive({ customer, productId: scale.id });
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 500,
|
||||
usage: 0,
|
||||
planId: scale.id,
|
||||
});
|
||||
});
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("attach patch config: customize billing behavior via remove/add")}`, async () => {
|
||||
const customerId = "attach-patch-config-billing-behavior";
|
||||
|
||||
// Original item is prepaid (must buy packs upfront).
|
||||
const scale = products.base({
|
||||
id: "scale",
|
||||
items: [
|
||||
items.monthlyPrice({ price: 20 }),
|
||||
items.prepaidMessages({ includedUsage: 0, billingUnits: 100, price: 10 }),
|
||||
],
|
||||
});
|
||||
|
||||
const { autumnV2_2 } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [scale] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Swap prepaid -> usage-based with included: no upfront pack charge.
|
||||
const attachParams: AttachParamsV1Input = {
|
||||
customer_id: customerId,
|
||||
plan_id: scale.id,
|
||||
customize: {
|
||||
remove_items: [{ feature_id: TestFeature.Messages }],
|
||||
add_items: [
|
||||
{
|
||||
feature_id: TestFeature.Messages,
|
||||
included: 100,
|
||||
price: {
|
||||
amount: 0.1,
|
||||
interval: BillingInterval.Month,
|
||||
billing_method: BillingMethod.UsageBased,
|
||||
billing_units: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Usage-based -> only the base price is charged now (billing behavior changed).
|
||||
const preview =
|
||||
await autumnV2_2.billing.previewAttach<AttachParamsV1Input>(attachParams);
|
||||
expect(preview.total).toBe(20);
|
||||
|
||||
await autumnV2_2.billing.attach<AttachParamsV1Input>(attachParams);
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
await expectProductActive({ customer, productId: scale.id });
|
||||
await expectCustomerProducts({ customer, active: [scale.id] });
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
remaining: 100,
|
||||
usage: 0,
|
||||
planId: scale.id,
|
||||
});
|
||||
});
|
||||
632
server/tests/scenarios/agent/email-platform.ts
Normal file
632
server/tests/scenarios/agent/email-platform.ts
Normal file
@@ -0,0 +1,632 @@
|
||||
import {
|
||||
AppEnv,
|
||||
FeatureUsageType,
|
||||
type FreeTrial,
|
||||
FreeTrialDuration,
|
||||
type ProductItem,
|
||||
type ProductV2,
|
||||
TierBehavior,
|
||||
} from "@autumn/shared";
|
||||
import { items } from "@tests/utils/fixtures/items";
|
||||
import { products } from "@tests/utils/fixtures/products";
|
||||
import { clearOrg } from "@tests/utils/setup/clearOrg.js";
|
||||
import defaultCtx, {
|
||||
createTestContext,
|
||||
type TestContext,
|
||||
} from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||
import { FeatureService } from "@/internal/features/FeatureService";
|
||||
import {
|
||||
constructBooleanFeature,
|
||||
constructCreditSystem,
|
||||
constructMeteredFeature,
|
||||
} from "@/internal/features/utils/constructFeatureUtils";
|
||||
import {
|
||||
constructFeatureItem,
|
||||
constructPrepaidItem,
|
||||
} from "@/utils/scriptUtils/constructItem";
|
||||
import {
|
||||
buildRealisticCustomerSeed,
|
||||
createScenarioAutumn,
|
||||
seedCustomersWithEntities,
|
||||
} from "../seedUtils";
|
||||
|
||||
// ── Email platform seed config ──
|
||||
// Modeled loosely on Resend's pricing with deliberately tweaked numbers and
|
||||
// renamed feature flags so it is NOT an accurate mirror. Transactional plans have
|
||||
// NO base price: email volume is a prepaid item (volume-priced tiers) plus a
|
||||
// consumable overage. AI credits are a credit system.
|
||||
// Defaults for `bun scenario ep`; CLI flags override (e.g. `--count 1000`).
|
||||
const EP_SEED = {
|
||||
customerCount: 0, // 0 = catalog only (features + plans, no customers)
|
||||
concurrency: 10,
|
||||
attachPlan: null as "free" | "enterprise" | null,
|
||||
clear: true, // wipe org before seeding; --skip-clear keeps existing
|
||||
};
|
||||
|
||||
export const emailPlatformFeatureIds = {
|
||||
ai_actions: "ai_actions",
|
||||
ai_credits: "ai_credits",
|
||||
automation_runs: "automation_runs",
|
||||
contacts: "contacts",
|
||||
dedicated_ip: "dedicated_ip",
|
||||
domains: "domains",
|
||||
emails: "emails",
|
||||
engagement_tracking: "engagement_tracking",
|
||||
inbound_routing: "inbound_routing",
|
||||
multi_region: "multi_region",
|
||||
no_daily_limit: "no_daily_limit",
|
||||
priority_support: "priority_support",
|
||||
projects: "projects",
|
||||
scoped_api_keys: "scoped_api_keys",
|
||||
sending_receiving: "sending_receiving",
|
||||
slack_support: "slack_support",
|
||||
smtp_relay: "smtp_relay",
|
||||
soc2_reports: "soc2_reports",
|
||||
sso_saml: "sso_saml",
|
||||
ticket_support: "ticket_support",
|
||||
webhooks: "webhooks",
|
||||
} as const;
|
||||
|
||||
export const emailPlatformPlanIds = {
|
||||
dedicatedIpPack: "dedicated_ip_pack",
|
||||
enterprise: "enterprise",
|
||||
free: "free",
|
||||
pro: "pro",
|
||||
proMarketing: "pro_marketing",
|
||||
scale: "scale",
|
||||
} as const;
|
||||
|
||||
// Boolean flags shared by every plan ("all plans include ...").
|
||||
const sharedBooleanFeatureIds = [
|
||||
emailPlatformFeatureIds.sending_receiving,
|
||||
emailPlatformFeatureIds.smtp_relay,
|
||||
emailPlatformFeatureIds.inbound_routing,
|
||||
emailPlatformFeatureIds.engagement_tracking,
|
||||
emailPlatformFeatureIds.multi_region,
|
||||
emailPlatformFeatureIds.webhooks,
|
||||
emailPlatformFeatureIds.soc2_reports,
|
||||
emailPlatformFeatureIds.scoped_api_keys,
|
||||
] as const;
|
||||
|
||||
// Tier-specific boolean flags.
|
||||
const tieredBooleanFeatureIds = [
|
||||
emailPlatformFeatureIds.ticket_support,
|
||||
emailPlatformFeatureIds.slack_support,
|
||||
emailPlatformFeatureIds.priority_support,
|
||||
emailPlatformFeatureIds.no_daily_limit,
|
||||
emailPlatformFeatureIds.dedicated_ip,
|
||||
emailPlatformFeatureIds.sso_saml,
|
||||
] as const;
|
||||
|
||||
export const emailPlatformBooleanFeatureIds = [
|
||||
...sharedBooleanFeatureIds,
|
||||
...tieredBooleanFeatureIds,
|
||||
] as const;
|
||||
|
||||
type EmailPlatformFeatureId =
|
||||
(typeof emailPlatformFeatureIds)[keyof typeof emailPlatformFeatureIds];
|
||||
|
||||
const featureNames: Record<EmailPlatformFeatureId, string> = {
|
||||
ai_actions: "AI Actions",
|
||||
ai_credits: "AI Credits",
|
||||
automation_runs: "Automation Runs",
|
||||
contacts: "Contacts",
|
||||
dedicated_ip: "Dedicated IP",
|
||||
domains: "Domains",
|
||||
emails: "Emails",
|
||||
engagement_tracking: "Engagement Tracking",
|
||||
inbound_routing: "Inbound Routing",
|
||||
multi_region: "Multi-Region Sending",
|
||||
no_daily_limit: "No Daily Limit",
|
||||
priority_support: "Priority Support",
|
||||
projects: "Projects",
|
||||
scoped_api_keys: "Scoped API Keys",
|
||||
sending_receiving: "Sending & Receiving",
|
||||
slack_support: "Slack Support",
|
||||
smtp_relay: "SMTP Relay",
|
||||
soc2_reports: "SOC 2 Reports",
|
||||
sso_saml: "SSO / SAML",
|
||||
ticket_support: "Ticket Support",
|
||||
webhooks: "Webhooks",
|
||||
};
|
||||
|
||||
type EmailPlatformPlanMap = ReturnType<
|
||||
typeof buildEmailPlatformProducts
|
||||
>["plans"];
|
||||
|
||||
export type EmailPlatformPlanKey = keyof EmailPlatformPlanMap;
|
||||
|
||||
const booleanItem = (featureId: EmailPlatformFeatureId): ProductItem =>
|
||||
constructFeatureItem({
|
||||
featureId,
|
||||
isBoolean: true,
|
||||
}) as ProductItem;
|
||||
|
||||
const booleanItems = (featureIds: readonly EmailPlatformFeatureId[]) =>
|
||||
featureIds.map(booleanItem);
|
||||
|
||||
type VolumeTier = { amount: number; to: number | "inf"; flat_amount: number };
|
||||
|
||||
// Prepaid email volume (whole purchased quantity billed at the tier flat amount)
|
||||
// plus a per-1k consumable overage for sends beyond the purchased volume.
|
||||
const emailItems = ({
|
||||
tiers,
|
||||
overagePerThousand,
|
||||
}: {
|
||||
tiers: VolumeTier[];
|
||||
overagePerThousand: number;
|
||||
}) => [
|
||||
constructPrepaidItem({
|
||||
featureId: emailPlatformFeatureIds.emails,
|
||||
billingUnits: 1,
|
||||
includedUsage: 0,
|
||||
tierBehaviour: TierBehavior.VolumeBased,
|
||||
tiers,
|
||||
}) as ProductItem,
|
||||
items.consumable({
|
||||
featureId: emailPlatformFeatureIds.emails,
|
||||
billingUnits: 1_000,
|
||||
price: overagePerThousand,
|
||||
}),
|
||||
];
|
||||
|
||||
// 10k automation runs included, then per-run overage.
|
||||
const automationItems = () => [
|
||||
items.consumable({
|
||||
featureId: emailPlatformFeatureIds.automation_runs,
|
||||
includedUsage: 10_000,
|
||||
billingUnits: 1,
|
||||
price: 0.0015,
|
||||
}),
|
||||
];
|
||||
|
||||
const proEmailTiers: VolumeTier[] = [
|
||||
{ amount: 0, to: 50_000, flat_amount: 20 },
|
||||
{ amount: 0, to: 100_000, flat_amount: 35 },
|
||||
{ amount: 0, to: "inf", flat_amount: 60 },
|
||||
];
|
||||
|
||||
const scaleEmailTiers: VolumeTier[] = [
|
||||
{ amount: 0, to: 100_000, flat_amount: 90 },
|
||||
{ amount: 0, to: 200_000, flat_amount: 160 },
|
||||
{ amount: 0, to: 500_000, flat_amount: 350 },
|
||||
{ amount: 0, to: 1_000_000, flat_amount: 650 },
|
||||
{ amount: 0, to: 1_500_000, flat_amount: 825 },
|
||||
{ amount: 0, to: 2_500_000, flat_amount: 1_150 },
|
||||
{ amount: 0, to: "inf", flat_amount: 1_500 },
|
||||
];
|
||||
|
||||
// Marketing is capped by contacts, not emails: prepaid contact tiers, no overage.
|
||||
const marketingContactTiers: VolumeTier[] = [
|
||||
{ amount: 0, to: 5_000, flat_amount: 40 },
|
||||
{ amount: 0, to: 10_000, flat_amount: 80 },
|
||||
{ amount: 0, to: 15_000, flat_amount: 120 },
|
||||
{ amount: 0, to: 25_000, flat_amount: 180 },
|
||||
{ amount: 0, to: 50_000, flat_amount: 250 },
|
||||
{ amount: 0, to: 100_000, flat_amount: 450 },
|
||||
{ amount: 0, to: 150_000, flat_amount: 650 },
|
||||
{ amount: 0, to: "inf", flat_amount: 900 },
|
||||
];
|
||||
|
||||
export const buildEmailPlatformFeatures = ({ ctx }: { ctx: TestContext }) => {
|
||||
const f = emailPlatformFeatureIds;
|
||||
const orgId = ctx.org.id;
|
||||
const env = ctx.env;
|
||||
|
||||
return [
|
||||
constructMeteredFeature({
|
||||
featureId: f.emails,
|
||||
name: featureNames[f.emails],
|
||||
orgId,
|
||||
env,
|
||||
usageType: FeatureUsageType.Single,
|
||||
eventNames: ["emails"],
|
||||
}),
|
||||
constructMeteredFeature({
|
||||
featureId: f.automation_runs,
|
||||
name: featureNames[f.automation_runs],
|
||||
orgId,
|
||||
env,
|
||||
usageType: FeatureUsageType.Single,
|
||||
eventNames: ["automation_runs"],
|
||||
}),
|
||||
constructMeteredFeature({
|
||||
featureId: f.ai_actions,
|
||||
name: featureNames[f.ai_actions],
|
||||
orgId,
|
||||
env,
|
||||
usageType: FeatureUsageType.Single,
|
||||
eventNames: ["ai_actions"],
|
||||
}),
|
||||
constructCreditSystem({
|
||||
featureId: f.ai_credits,
|
||||
orgId,
|
||||
env,
|
||||
schema: [{ metered_feature_id: f.ai_actions, credit_cost: 1 }],
|
||||
}),
|
||||
constructMeteredFeature({
|
||||
featureId: f.contacts,
|
||||
name: featureNames[f.contacts],
|
||||
orgId,
|
||||
env,
|
||||
usageType: FeatureUsageType.Continuous,
|
||||
}),
|
||||
constructMeteredFeature({
|
||||
featureId: f.domains,
|
||||
name: featureNames[f.domains],
|
||||
orgId,
|
||||
env,
|
||||
usageType: FeatureUsageType.Continuous,
|
||||
}),
|
||||
constructMeteredFeature({
|
||||
featureId: f.projects,
|
||||
name: featureNames[f.projects],
|
||||
orgId,
|
||||
env,
|
||||
usageType: FeatureUsageType.Continuous,
|
||||
}),
|
||||
...emailPlatformBooleanFeatureIds.map((featureId) =>
|
||||
constructBooleanFeature({
|
||||
featureId,
|
||||
name: featureNames[featureId],
|
||||
orgId,
|
||||
env,
|
||||
}),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
export const ensureEmailPlatformFeatures = async ({
|
||||
ctx = defaultCtx,
|
||||
}: {
|
||||
ctx?: TestContext;
|
||||
} = {}) => {
|
||||
const desiredFeatures = buildEmailPlatformFeatures({ ctx });
|
||||
const existingFeatures = await FeatureService.list({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
const existingById = new Map(
|
||||
existingFeatures.map((feature) => [feature.id, feature]),
|
||||
);
|
||||
|
||||
const featuresToInsert = desiredFeatures.filter(
|
||||
(feature) => !existingById.has(feature.id),
|
||||
);
|
||||
const featuresToUpdate = desiredFeatures.filter((feature) =>
|
||||
existingById.has(feature.id),
|
||||
);
|
||||
|
||||
if (featuresToInsert.length > 0) {
|
||||
await FeatureService.insert({
|
||||
db: ctx.db,
|
||||
data: featuresToInsert,
|
||||
logger: console,
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
featuresToUpdate.map((feature) =>
|
||||
FeatureService.update({
|
||||
db: ctx.db,
|
||||
id: feature.id,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
updates: {
|
||||
name: feature.name,
|
||||
type: feature.type,
|
||||
config: feature.config,
|
||||
event_names: feature.event_names,
|
||||
model_markups: feature.model_markups,
|
||||
archived: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
ctx.features = await FeatureService.list({
|
||||
db: ctx.db,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
});
|
||||
|
||||
return ctx.features.filter((feature) =>
|
||||
desiredFeatures.some((desired) => desired.id === feature.id),
|
||||
);
|
||||
};
|
||||
|
||||
export const buildEmailPlatformProducts = () => {
|
||||
const f = emailPlatformFeatureIds;
|
||||
const p = emailPlatformPlanIds;
|
||||
|
||||
const freeBooleans = [...sharedBooleanFeatureIds, f.ticket_support] as const;
|
||||
const proBooleans = [
|
||||
...sharedBooleanFeatureIds,
|
||||
f.ticket_support,
|
||||
f.no_daily_limit,
|
||||
] as const;
|
||||
const scaleBooleans = [
|
||||
...sharedBooleanFeatureIds,
|
||||
f.slack_support,
|
||||
f.no_daily_limit,
|
||||
] as const;
|
||||
const enterpriseBooleans = [
|
||||
...sharedBooleanFeatureIds,
|
||||
f.priority_support,
|
||||
f.no_daily_limit,
|
||||
f.sso_saml,
|
||||
f.dedicated_ip,
|
||||
] as const;
|
||||
|
||||
const plans = {
|
||||
// Free: fixed allowances, daily-limited, no overage on automations.
|
||||
free: products.base({
|
||||
id: p.free,
|
||||
items: [
|
||||
items.free({ featureId: f.emails, includedUsage: 3_000 }),
|
||||
items.free({ featureId: f.automation_runs, includedUsage: 10_000 }),
|
||||
items.free({ featureId: f.ai_credits, includedUsage: 5 }),
|
||||
items.free({ featureId: f.domains, includedUsage: 1 }),
|
||||
...booleanItems(freeBooleans),
|
||||
],
|
||||
}),
|
||||
// Pro/Scale: NO base price — email volume is the prepaid item.
|
||||
pro: products.base({
|
||||
id: p.pro,
|
||||
items: [
|
||||
...emailItems({ tiers: proEmailTiers, overagePerThousand: 0.9 }),
|
||||
...automationItems(),
|
||||
items.free({ featureId: f.ai_credits, includedUsage: 100 }),
|
||||
items.free({ featureId: f.domains, includedUsage: 10 }),
|
||||
...booleanItems(proBooleans),
|
||||
],
|
||||
}),
|
||||
scale: products.base({
|
||||
id: p.scale,
|
||||
items: [
|
||||
...emailItems({ tiers: scaleEmailTiers, overagePerThousand: 0.5 }),
|
||||
...automationItems(),
|
||||
items.free({ featureId: f.ai_credits, includedUsage: 500 }),
|
||||
items.free({ featureId: f.domains, includedUsage: 1_000 }),
|
||||
...booleanItems(scaleBooleans),
|
||||
],
|
||||
}),
|
||||
// Marketing: capped by contacts (prepaid tiers), not email volume.
|
||||
proMarketing: products.base({
|
||||
id: p.proMarketing,
|
||||
items: [
|
||||
constructPrepaidItem({
|
||||
featureId: f.contacts,
|
||||
billingUnits: 1,
|
||||
includedUsage: 0,
|
||||
tierBehaviour: TierBehavior.VolumeBased,
|
||||
tiers: marketingContactTiers,
|
||||
}) as ProductItem,
|
||||
items.free({ featureId: f.ai_credits, includedUsage: 100 }),
|
||||
...booleanItems([...sharedBooleanFeatureIds, f.no_daily_limit]),
|
||||
],
|
||||
}),
|
||||
// Enterprise: custom, no base price, flexible everything.
|
||||
enterprise: products.base({
|
||||
id: p.enterprise,
|
||||
items: [
|
||||
items.free({ featureId: f.emails, includedUsage: 3_000_000 }),
|
||||
...automationItems(),
|
||||
items.free({ featureId: f.ai_credits, includedUsage: 5_000 }),
|
||||
items.free({ featureId: f.domains, includedUsage: 5_000 }),
|
||||
items.free({ featureId: f.contacts, includedUsage: 150_000 }),
|
||||
...booleanItems(enterpriseBooleans),
|
||||
],
|
||||
}),
|
||||
dedicatedIpPack: products.base({
|
||||
id: p.dedicatedIpPack,
|
||||
isAddOn: true,
|
||||
items: [items.monthlyPrice({ price: 30 }), booleanItem(f.dedicated_ip)],
|
||||
}),
|
||||
} satisfies Record<string, ProductV2>;
|
||||
|
||||
return {
|
||||
featureIds: emailPlatformFeatureIds,
|
||||
planIds: emailPlatformPlanIds,
|
||||
proBooleans,
|
||||
scaleBooleans,
|
||||
enterpriseBooleans,
|
||||
plans,
|
||||
};
|
||||
};
|
||||
|
||||
const withProductGroup = ({
|
||||
products,
|
||||
group,
|
||||
}: {
|
||||
products: ProductV2[];
|
||||
group: string;
|
||||
}) =>
|
||||
products.map((product) => ({
|
||||
...product,
|
||||
group,
|
||||
}));
|
||||
|
||||
export const initEmailPlatformScenario = async ({
|
||||
customerId = "agent-email-platform",
|
||||
attachPlan = "free",
|
||||
entityCount = 2,
|
||||
paymentMethod = "success",
|
||||
ctx = defaultCtx,
|
||||
}: {
|
||||
customerId?: string;
|
||||
attachPlan?: EmailPlatformPlanKey | null;
|
||||
entityCount?: number;
|
||||
paymentMethod?: "success" | "fail" | "authenticate" | "alipay";
|
||||
ctx?: TestContext;
|
||||
} = {}) => {
|
||||
await ensureEmailPlatformFeatures({ ctx });
|
||||
|
||||
const catalog = buildEmailPlatformProducts();
|
||||
const setup = [
|
||||
s.customer({ paymentMethod }),
|
||||
s.products({ list: Object.values(catalog.plans) }),
|
||||
...(entityCount > 0
|
||||
? [
|
||||
s.entities({
|
||||
count: entityCount,
|
||||
featureId: catalog.featureIds.projects,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const actions = attachPlan
|
||||
? [s.billing.attach({ productId: catalog.plans[attachPlan].id })]
|
||||
: [];
|
||||
const scenario = await initScenario({
|
||||
customerId,
|
||||
setup,
|
||||
actions,
|
||||
ctx,
|
||||
});
|
||||
|
||||
return {
|
||||
...scenario,
|
||||
...catalog,
|
||||
};
|
||||
};
|
||||
|
||||
/** Return a copy of a plan with a card-required free trial, for trialing-state scenarios. */
|
||||
export const withFreeTrial = ({
|
||||
product,
|
||||
trialDays,
|
||||
}: {
|
||||
product: ProductV2;
|
||||
trialDays: number;
|
||||
}): ProductV2 => ({
|
||||
...product,
|
||||
free_trial: {
|
||||
length: trialDays,
|
||||
duration: FreeTrialDuration.Day,
|
||||
unique_fingerprint: false,
|
||||
card_required: true,
|
||||
} as unknown as FreeTrial,
|
||||
});
|
||||
|
||||
export const seedEmailPlatformCustomers = async ({
|
||||
customerCount = 1_000,
|
||||
idPrefix = "ep-customer",
|
||||
entityCountForCustomer,
|
||||
productPrefix = "email-platform",
|
||||
attachPlan = null,
|
||||
concurrency = 10,
|
||||
deleteExisting = true,
|
||||
ctx = defaultCtx,
|
||||
}: {
|
||||
customerCount?: number;
|
||||
idPrefix?: string;
|
||||
entityCountForCustomer?: (index: number) => 1 | 2;
|
||||
productPrefix?: string;
|
||||
attachPlan?: Extract<EmailPlatformPlanKey, "free" | "enterprise"> | null;
|
||||
concurrency?: number;
|
||||
deleteExisting?: boolean;
|
||||
ctx?: TestContext;
|
||||
} = {}) => {
|
||||
await ensureEmailPlatformFeatures({ ctx });
|
||||
|
||||
const catalog = buildEmailPlatformProducts();
|
||||
await initScenario({
|
||||
setup: [
|
||||
s.products({
|
||||
list: withProductGroup({
|
||||
products: Object.values(catalog.plans),
|
||||
group: productPrefix,
|
||||
}),
|
||||
prefix: "",
|
||||
createInStripe: false,
|
||||
}),
|
||||
],
|
||||
actions: [],
|
||||
ctx,
|
||||
});
|
||||
|
||||
const customers = Array.from({ length: customerCount }, (_, index) => {
|
||||
return {
|
||||
...buildRealisticCustomerSeed({
|
||||
index,
|
||||
idPrefix,
|
||||
entityFeatureId: emailPlatformFeatureIds.projects,
|
||||
entityCount: entityCountForCustomer?.(index),
|
||||
}),
|
||||
attachPlanId: attachPlan ? catalog.plans[attachPlan].id : null,
|
||||
};
|
||||
});
|
||||
|
||||
const seeded = await seedCustomersWithEntities({
|
||||
autumn: createScenarioAutumn({ ctx }),
|
||||
customers,
|
||||
concurrency,
|
||||
deleteExisting,
|
||||
});
|
||||
|
||||
return {
|
||||
...catalog,
|
||||
productPrefix,
|
||||
...seeded,
|
||||
};
|
||||
};
|
||||
|
||||
const getArgValue = (name: string) => {
|
||||
const prefix = `${name}=`;
|
||||
const inline = process.argv.find((arg) => arg.startsWith(prefix));
|
||||
if (inline) return inline.slice(prefix.length);
|
||||
|
||||
const index = process.argv.indexOf(name);
|
||||
return index === -1 ? undefined : process.argv[index + 1];
|
||||
};
|
||||
|
||||
export const runEmailPlatformSeed = async () => {
|
||||
const customerCount = Number(getArgValue("--count") ?? EP_SEED.customerCount);
|
||||
const concurrency = Number(
|
||||
getArgValue("--concurrency") ?? EP_SEED.concurrency,
|
||||
);
|
||||
const attachPlan = (getArgValue("--attach-plan") ?? EP_SEED.attachPlan) as
|
||||
| Extract<EmailPlatformPlanKey, "free" | "enterprise">
|
||||
| null;
|
||||
if (!process.env.TESTS_ORG) {
|
||||
throw new Error("TESTS_ORG is required to seed email platform data");
|
||||
}
|
||||
|
||||
if (EP_SEED.clear && !process.argv.includes("--skip-clear")) {
|
||||
await clearOrg({
|
||||
orgSlug: process.env.TESTS_ORG,
|
||||
env: AppEnv.Sandbox,
|
||||
skipStripeReset: true,
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = await createTestContext();
|
||||
|
||||
const result = await seedEmailPlatformCustomers({
|
||||
ctx,
|
||||
customerCount,
|
||||
concurrency,
|
||||
attachPlan: attachPlan ?? null,
|
||||
deleteExisting: !process.argv.includes("--keep-existing"),
|
||||
});
|
||||
|
||||
console.log("Email platform seed complete", {
|
||||
customers: result.customerCount,
|
||||
entities: result.entityCount,
|
||||
productPrefix: result.productPrefix,
|
||||
attachPlan: attachPlan ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
if (import.meta.main) {
|
||||
runEmailPlatformSeed()
|
||||
.catch((error) => {
|
||||
console.error("Email platform seed failed:", error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -38,3 +38,29 @@ test(`${chalk.yellowBright("agent: knowledge platform customer mid-cycle trialin
|
||||
// Advanced 7 days into a 14-day trial: still trialing, ends in the future.
|
||||
expect(subscription?.trial_ends_at).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
test(`${chalk.yellowBright("agent: knowledge platform customer on paid scale")}`, async () => {
|
||||
await ensureKnowledgePlatformFeatures();
|
||||
const { plans, featureIds } = buildKnowledgePlatformProducts();
|
||||
const planList = Object.values(plans);
|
||||
|
||||
const { autumnV2_2, customerId } = await initScenario({
|
||||
customerId: "agent-kp-paid-scale",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: planList }),
|
||||
s.entities({ count: 1, featureId: featureIds.workspaces }),
|
||||
],
|
||||
actions: [
|
||||
s.billing.attach({ productId: plans.scale.id }),
|
||||
s.advanceTestClock({ days: 7, waitForSeconds: 15 }),
|
||||
],
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
const subscription = getSubscription(customer, plans.scale.id);
|
||||
|
||||
// Paid scale: active subscription, not trialing.
|
||||
expect(subscription).toBeDefined();
|
||||
expect(subscription?.trial_ends_at).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from "../seedUtils";
|
||||
|
||||
// ── Knowledge platform seed config ──
|
||||
// Defaults for `bun kp`; CLI flags override (e.g. `bun kp --count 1000`).
|
||||
// Defaults for `bun scenario kp`; CLI flags override (e.g. `bun scenario kp --count 1000`).
|
||||
const KP_SEED = {
|
||||
customerCount: 0, // 0 = catalog only (features + plans, no customers)
|
||||
concurrency: 10,
|
||||
@@ -503,7 +503,7 @@ const getArgValue = (name: string) => {
|
||||
return index === -1 ? undefined : process.argv[index + 1];
|
||||
};
|
||||
|
||||
const runKnowledgePlatformSeed = async () => {
|
||||
export const runKnowledgePlatformSeed = async () => {
|
||||
const customerCount = Number(getArgValue("--count") ?? KP_SEED.customerCount);
|
||||
const concurrency = Number(getArgValue("--concurrency") ?? KP_SEED.concurrency);
|
||||
const attachPlan = (getArgValue("--attach-plan") ?? KP_SEED.attachPlan) as
|
||||
|
||||
37
server/tests/scenarios/agent/scenario.ts
Normal file
37
server/tests/scenarios/agent/scenario.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Dispatches `bun scenario <ep | kp | email | knowledge> [flags]` to the matching
|
||||
// agent seed. Flags (--count, --concurrency, --attach-plan, --skip-clear,
|
||||
// --keep-existing) are read from process.argv by each seed and pass through.
|
||||
import { runEmailPlatformSeed } from "./email-platform.js";
|
||||
import { runKnowledgePlatformSeed } from "./knowledge-platform.js";
|
||||
|
||||
const scenarios = {
|
||||
email: runEmailPlatformSeed,
|
||||
ep: runEmailPlatformSeed,
|
||||
knowledge: runKnowledgePlatformSeed,
|
||||
kp: runKnowledgePlatformSeed,
|
||||
} as const;
|
||||
|
||||
type ScenarioKey = keyof typeof scenarios;
|
||||
|
||||
const isScenarioKey = (arg: string): arg is ScenarioKey => arg in scenarios;
|
||||
|
||||
const run = async () => {
|
||||
const key = process.argv.slice(2).find(isScenarioKey);
|
||||
if (!key) {
|
||||
console.error(
|
||||
"Usage: bun scenario <ep | kp | email | knowledge> [--count N] [--concurrency N] [--attach-plan trial|enterprise] [--skip-clear] [--keep-existing]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await scenarios[key]();
|
||||
};
|
||||
|
||||
run()
|
||||
.catch((error) => {
|
||||
console.error("Scenario seed failed:", error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in New Issue
Block a user