added patch to attach and knowledge of trials

This commit is contained in:
johnyeo
2026-06-14 14:59:36 +01:00
parent ceb1228d76
commit 33b7aa61b4
39 changed files with 1519 additions and 114 deletions

View File

@@ -34,6 +34,7 @@ export const claudeManagedEngine: AgentEngine = {
onActionKeyed, onActionKeyed,
onAgentReady, onAgentReady,
onApprovalsSuperseded, onApprovalsSuperseded,
onThinking,
org, org,
providerUserId, providerUserId,
thread, thread,
@@ -169,6 +170,7 @@ export const claudeManagedEngine: AgentEngine = {
logger, logger,
onAction, onAction,
onActionKeyed, onActionKeyed,
onThinking,
onTurnEnd, onTurnEnd,
orgId: org.id, orgId: org.id,
previewCapture, previewCapture,

View File

@@ -36,6 +36,7 @@ export const runMessage = async ({
onActionKeyed, onActionKeyed,
onAgentReady, onAgentReady,
onApprovalsSuperseded, onApprovalsSuperseded,
onThinking,
onTurnComplete, onTurnComplete,
providerUserId, providerUserId,
recentMessages, recentMessages,
@@ -142,6 +143,7 @@ export const runMessage = async ({
onActionKeyed, onActionKeyed,
onAgentReady, onAgentReady,
onApprovalsSuperseded, onApprovalsSuperseded,
onThinking,
org: { org: {
id: installation.org_id, id: installation.org_id,
slug: installation.org_slug ?? undefined, slug: installation.org_slug ?? undefined,

View File

@@ -42,6 +42,8 @@ export type MessageContext = {
onApprovalsSuperseded?: (approvals: ChatApproval[]) => Promise<void> | void; onApprovalsSuperseded?: (approvals: ChatApproval[]) => Promise<void> | void;
/** Fires once the managed agent is ready to run its first turn (startup done). */ /** Fires once the managed agent is ready to run its first turn (startup done). */
onAgentReady?: () => Promise<void> | void; 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. */ /** Posts a drained intermediate turn's text while follow-ups keep the run alive. */
onTurnComplete?: (text: string) => Promise<void> | void; onTurnComplete?: (text: string) => Promise<void> | void;
org: { id: string; slug?: string }; org: { id: string; slug?: string };

View File

@@ -10,7 +10,7 @@ import {
type createPreviewCapture, type createPreviewCapture,
getWriteToolForPreview, getWriteToolForPreview,
isSilentTool, isSilentTool,
toolLabel, toolGerund,
} from "./toolPolicy.js"; } from "./toolPolicy.js";
type AutumnTool = { type AutumnTool = {
@@ -52,7 +52,7 @@ export const formatToolAction = ({
typeof value === "string" && value ? [`${label}: ${value}`] : [], typeof value === "string" && value ? [`${label}: ${value}`] : [],
); );
return `${toolLabel(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`; return `${toolGerund(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`;
}; };
export const getAutumnMcpTools = async ({ export const getAutumnMcpTools = async ({

View File

@@ -35,6 +35,36 @@ export const toolLabel = (toolName: string) =>
.replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/^./, (char) => char.toUpperCase()); .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 = { export type PreviewApproval = {
preview: unknown; preview: unknown;
toolArgs: Record<string, unknown>; toolArgs: Record<string, unknown>;

View File

@@ -36,13 +36,12 @@ import { findInstallationWithOrg } from "./providers/slack/installations.js";
import { getRecentMessages } from "./providers/slack/threadContext.js"; import { getRecentMessages } from "./providers/slack/threadContext.js";
import type { ChatContextMessage } from "./types.js"; import type { ChatContextMessage } from "./types.js";
import { import {
createActionLogger,
createKeyedActionLogger,
finishLoading, finishLoading,
type LoadingState, type LoadingState,
type ReplyTarget, type ReplyTarget,
startLoading, startLoading,
} from "./ui/progress.js"; } from "./ui/progress.js";
import { createStatusTicker } from "./ui/statusTicker.js";
export const chatAdapterNames = ["slack"]; export const chatAdapterNames = ["slack"];
@@ -146,6 +145,7 @@ const runAndReply = async ({
let bootstrapLoading: LoadingState = null; let bootstrapLoading: LoadingState = null;
let logger = rootLogger; let logger = rootLogger;
let run: ActiveRun | undefined; let run: ActiveRun | undefined;
const ticker = createStatusTicker(target);
try { try {
const workspaceId = getSlackWorkspaceId(raw); const workspaceId = getSlackWorkspaceId(raw);
const installation = await findSlackInstallationForWorkspace({ const installation = await findSlackInstallationForWorkspace({
@@ -192,23 +192,23 @@ const runAndReply = async ({
bootstrapLoading = isFollowUp bootstrapLoading = isFollowUp
? null ? null
: await startLoading(target, { showPlan: true }); : 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. // resolves, so the two loading states never show at the same time.
const startWorkingStatus = () => startLoading(target, { showPlan: false });
const completeBootstrap = async () => { const completeBootstrap = async () => {
if (!bootstrapLoading) return; if (!bootstrapLoading) return;
const card = bootstrapLoading; const card = bootstrapLoading;
bootstrapLoading = null; bootstrapLoading = null;
await finishLoading(target, card, "Autumn started."); await finishLoading(target, card, "Autumn started.");
await startWorkingStatus(); ticker.thinking();
}; };
run = registerRun({ key: runKey, kind: "message" }); 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) { if (isFollowUp) {
await startWorkingStatus(); ticker.thinking();
} }
const logAction = createActionLogger(loading, target); const logAction = (message: string) => ticker.activity(message);
const logKeyed = createKeyedActionLogger(loading, target); const logKeyed = ({ message }: { key: string; message: string }) =>
ticker.activity(message);
run.logAction = logAction; run.logAction = logAction;
const rawFiles = getSlackFilesFromRaw({ raw }); const rawFiles = getSlackFilesFromRaw({ raw });
const botToken = decrypt(installation.bot_access_token); const botToken = decrypt(installation.bot_access_token);
@@ -229,6 +229,7 @@ const runAndReply = async ({
onAgentReady: completeBootstrap, onAgentReady: completeBootstrap,
onApprovalsSuperseded: (approvals) => onApprovalsSuperseded: (approvals) =>
editSupersededApprovalCards({ approvals, logger, target }), editSupersededApprovalCards({ approvals, logger, target }),
onThinking: ticker.thinking,
onTurnComplete: async (turnText) => { onTurnComplete: async (turnText) => {
await target.post({ markdown: turnText }); await target.post({ markdown: turnText });
}, },
@@ -289,6 +290,7 @@ const runAndReply = async ({
markdown: "I could not complete that request. Please try again.", markdown: "I could not complete that request. Please try again.",
}); });
} finally { } finally {
ticker.stop();
if (run) closeRun({ key: run.key, run }); if (run) closeRun({ key: run.key, run });
} }
}; };

View File

@@ -5,6 +5,11 @@ import { setupAgentToolContext } from "../../agent/runMessage/setup/setupAgentTo
import { env as chatEnv } from "../../lib/env.js"; import { env as chatEnv } from "../../lib/env.js";
import { autumnChatInstructions } from "../common/instructions/index.js"; import { autumnChatInstructions } from "../common/instructions/index.js";
import { claudeManagedConfig } from "./config.js"; import { claudeManagedConfig } from "./config.js";
import {
buildDesiredTools,
builtinSignatureFromToolset,
desiredBuiltinSignature,
} from "./toolset.js";
const isLoopback = (hostname: string) => const isLoopback = (hostname: string) =>
hostname === "localhost" || hostname === "localhost" ||
@@ -44,31 +49,43 @@ export const buildAgentSystem = ({ docsText }: { docsText: string }) =>
[autumnChatInstructions, docsText].filter(Boolean).join("\n\n"); [autumnChatInstructions, docsText].filter(Boolean).join("\n\n");
// Keep the shared agent config in sync with local code/tunnel changes. // 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; let agentConfigSynced = false;
const syncAgentConfig = async ({ const syncAgentConfig = async ({
agentId, agentId,
client, client,
destructiveTools,
docsText, docsText,
expectedUrl, expectedUrl,
logger, logger,
}: { }: {
agentId: string; agentId: string;
client: Anthropic; client: Anthropic;
destructiveTools: Iterable<string>;
docsText: string; docsText: string;
expectedUrl: string; expectedUrl: string;
logger: AutumnLogger; logger: AutumnLogger;
}) => { }) => {
if (agentConfigSynced) return; if (agentConfigSynced && !alwaysResync) return;
const agent = await client.beta.agents.retrieve(agentId); const agent = await client.beta.agents.retrieve(agentId);
const expectedSystem = buildAgentSystem({ docsText }); const expectedSystem = buildAgentSystem({ docsText });
const currentUrl = agent.mcp_servers?.find( const currentUrl = agent.mcp_servers?.find(
(server) => server.name === claudeManagedConfig.autumnMcpServerName, (server) => server.name === claudeManagedConfig.autumnMcpServerName,
)?.url; )?.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 mcpUrlChanged = currentUrl !== expectedUrl;
const systemChanged = agent.system !== expectedSystem; const systemChanged = agent.system !== expectedSystem;
const modelChanged = agent.model.id !== claudeManagedConfig.model; 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, { await client.beta.agents.update(agentId, {
...(mcpUrlChanged ...(mcpUrlChanged
? { ? {
@@ -83,6 +100,9 @@ const syncAgentConfig = async ({
: {}), : {}),
...(systemChanged ? { system: expectedSystem } : {}), ...(systemChanged ? { system: expectedSystem } : {}),
...(modelChanged ? { model: claudeManagedConfig.model } : {}), ...(modelChanged ? { model: claudeManagedConfig.model } : {}),
...(toolsChanged
? { tools: buildDesiredTools({ destructiveTools }) }
: {}),
version: agent.version, version: agent.version,
}); });
logger.info("Refreshed Claude Managed agent config", { logger.info("Refreshed Claude Managed agent config", {
@@ -96,6 +116,9 @@ const syncAgentConfig = async ({
model_changed: modelChanged, model_changed: modelChanged,
model_from: agent.model.id, model_from: agent.model.id,
model_to: claudeManagedConfig.model, 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; token: string;
}): Promise<{ agentId: string; environmentId: string }> => { }): Promise<{ agentId: string; environmentId: string }> => {
const mcpUrl = autumnMcpUrl(); const mcpUrl = autumnMcpUrl();
if (cachedResources && agentConfigSynced) return cachedResources; if (cachedResources && agentConfigSynced && !alwaysResync) {
return cachedResources;
}
const { destructiveTools, docsText } = await setupAgentToolContext({ const { destructiveTools, docsText } = await setupAgentToolContext({
env, env,
@@ -129,6 +154,7 @@ export const ensureLeafResources = async ({
await syncAgentConfig({ await syncAgentConfig({
agentId: cachedResources.agentId, agentId: cachedResources.agentId,
client, client,
destructiveTools,
docsText, docsText,
expectedUrl: mcpUrl, expectedUrl: mcpUrl,
logger, logger,
@@ -153,6 +179,7 @@ export const ensureLeafResources = async ({
await syncAgentConfig({ await syncAgentConfig({
agentId, agentId,
client, client,
destructiveTools,
docsText, docsText,
expectedUrl: mcpUrl, expectedUrl: mcpUrl,
logger, logger,
@@ -169,19 +196,7 @@ export const ensureLeafResources = async ({
url: mcpUrl, url: mcpUrl,
}, },
], ],
tools: [ tools: buildDesiredTools({ destructiveTools }),
// 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 },
})),
},
],
}); });
agentId = agent.id; agentId = agent.id;
agentConfigSynced = true; agentConfigSynced = true;

View File

@@ -17,6 +17,7 @@ export const driveSessionTurn = async ({
onAutumnToolResult, onAutumnToolResult,
onSandboxTool, onSandboxTool,
onSessionRetry, onSessionRetry,
onThinking,
onToolError, onToolError,
onTurnEnd, onTurnEnd,
sessionId, sessionId,
@@ -39,6 +40,8 @@ export const driveSessionTurn = async ({
name: string; name: string;
}) => Promise<void> | void; }) => Promise<void> | void;
onSessionRetry?: (input: { message: 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: { onToolError?: (input: {
name: string; name: string;
output: unknown; output: unknown;
@@ -120,6 +123,10 @@ export const driveSessionTurn = async ({
} }
} else if (event.type === "agent.tool_use") { } else if (event.type === "agent.tool_use") {
await onSandboxTool?.({ input: event.input, name: event.name }); 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") { } else if (event.type === "span.model_request_end") {
const usage = event.model_usage; const usage = event.model_usage;
outcome.usage.inputTokens += usage.input_tokens; outcome.usage.inputTokens += usage.input_tokens;

View File

@@ -76,6 +76,7 @@ export const runClaudeManagedTurn = async ({
logger, logger,
onAction, onAction,
onActionKeyed, onActionKeyed,
onThinking,
onTurnEnd, onTurnEnd,
orgId, orgId,
previewCapture, previewCapture,
@@ -89,6 +90,7 @@ export const runClaudeManagedTurn = async ({
logger: AutumnLogger; logger: AutumnLogger;
onAction?: (message: string) => Promise<void> | void; onAction?: (message: string) => Promise<void> | void;
onActionKeyed?: KeyedActionLogger; onActionKeyed?: KeyedActionLogger;
onThinking?: () => void;
onTurnEnd?: ( onTurnEnd?: (
turn: SessionTurnOutcome, turn: SessionTurnOutcome,
) => Promise<"continue" | "stop"> | "continue" | "stop"; ) => Promise<"continue" | "stop"> | "continue" | "stop";
@@ -147,6 +149,7 @@ export const runClaudeManagedTurn = async ({
}); });
} }
}, },
onThinking,
onSessionRetry: async ({ message }) => { onSessionRetry: async ({ message }) => {
logger.warn("Claude Managed session retrying", { logger.warn("Claude Managed session retrying", {
event: "leaf.claude_managed_session_retrying", event: "leaf.claude_managed_session_retrying",

View 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(",");
};

View File

@@ -19,8 +19,8 @@ Autumn:
${autumnMcpInstructions} ${autumnMcpInstructions}
Writes and approvals (overrides the MCP/billing approval steps above): 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. - 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. 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". - 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: Web search:
- Use web search only for current or external web context. - Use web search only for current or external web context.

View File

@@ -1,6 +1,5 @@
import type { MessageParams } from "../../agent/runMessage/types.js"; import type { MessageParams } from "../../agent/runMessage/types.js";
import type { AutumnOrgContext } from "../../internal/autumnMcp/orgContextService.js"; import type { AutumnOrgContext } from "../../internal/autumnMcp/orgContextService.js";
import { replyStyleInstructions } from "./instructions/index.js";
export const buildHarnessMessageText = ({ export const buildHarnessMessageText = ({
env, env,
@@ -14,7 +13,6 @@ export const buildHarnessMessageText = ({
params: MessageParams; params: MessageParams;
}) => { }) => {
const preamble = [ 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.`, `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 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}` ? `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}`

View File

@@ -1,3 +1,4 @@
// export const DEFAULT_CHAT_MODEL = "anthropic/claude-sonnet-4-6";
export const DEFAULT_CHAT_MODEL = "anthropic/claude-opus-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 // Cheap/fast model for the throwaway env classifier (sandbox vs live) — it doesn't

View File

@@ -89,6 +89,8 @@ export type BotMessage = {
onApprovalsSuperseded?: (approvals: ChatApproval[]) => Promise<void> | void; onApprovalsSuperseded?: (approvals: ChatApproval[]) => Promise<void> | void;
/** Fires once the managed agent is ready to run its first turn (startup done). */ /** Fires once the managed agent is ready to run its first turn (startup done). */
onAgentReady?: () => Promise<void> | void; 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; onTurnComplete?: (text: string) => Promise<void> | void;
providerUserId: string; providerUserId: string;
run?: ActiveRun; run?: ActiveRun;

View File

@@ -410,6 +410,9 @@ const modifierPhrases = (toolArgs?: Record<string, unknown>) => {
: enableImmediately === false : enableImmediately === false
? "access waits for payment" ? "access waits for payment"
: null, : null,
getString(request.plan_schedule)
? `plan schedule: ${request.plan_schedule}`
: null,
getString(request.proration_behavior) getString(request.proration_behavior)
? `proration: ${request.proration_behavior}` ? `proration: ${request.proration_behavior}`
: null, : null,

View 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();
},
};
};

View File

@@ -1,9 +1,7 @@
// Tests the agent's ability to diff a requested boolean-feature list against what // 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, // the Scale plan already grants, treating the list as the exhaustive set: features
// and compliance_controls, so those must be no-ops — only the two features missing from // on the plan but not listed must be removed, listed features missing from the plan
// the plan (hosted_solution, unlimited_seats) belong in add_items, and the two the user // must be added, and listed features already on the plan are no-ops.
// 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.
import { withCustomers } from "../../fixtures/createSetup.js"; import { withCustomers } from "../../fixtures/createSetup.js";
import { billing, response, tools } from "../../fixtures/expectations/index.js"; import { billing, response, tools } from "../../fixtures/expectations/index.js";
import { orgSetups } from "../../fixtures/orgSetups.js"; import { orgSetups } from "../../fixtures/orgSetups.js";
@@ -14,6 +12,13 @@ import {
user, user,
} from "../../harness/index.js"; } 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 = { type EvalMetadata = {
domain: "billing"; domain: "billing";
flow: "attach"; flow: "attach";
@@ -26,7 +31,7 @@ const setup = withCustomers({
customers: ({ customers }) => ({ customers: ({ customers }) => ({
meridian: customers.base({ meridian: customers.base({
email: "billing@meridiandata.example", email: "billing@meridiandata.example",
id: "kp-customer-0100", id: "kp-customer-0099",
name: "Meridian Data", name: "Meridian Data",
}), }),
}), }),
@@ -34,27 +39,35 @@ const setup = withCustomers({
workspace: entities.base({ workspace: entities.base({
customer: customers.meridian, customer: customers.meridian,
feature: features.workspaces, feature: features.workspaces,
id: "kp-customer-0100-main", id: "kp-customer-0099-main",
name: "Main", name: "Main",
}), }),
}), }),
}); });
// Only the two features Scale lacks are added; the two excluded are removed. // Add the two listed features Scale lacks; remove the nine Scale booleans the
// Anything already on the plan (priority_queue, approval_chains, compliance_controls) // list omits. The three listed features already on Scale (priority_queue,
// must NOT appear here. // approval_chains, compliance_controls) stay untouched.
const features = setup.refs.features;
const expectedAttachRequest = { const expectedAttachRequest = {
customer_id: setup.refs.customers.meridian.id, customer_id: setup.refs.customers.meridian.id,
entity_id: setup.refs.entities.workspace.id, entity_id: setup.refs.entities.workspace.id,
plan_id: setup.refs.plans.scale.id, plan_id: setup.refs.plans.scale.id,
customize: { customize: {
add_items: [ add_items: [
{ feature_id: setup.refs.features.hosted_solution.id, unlimited: true }, { feature_id: features.hosted_solution.id, unlimited: true },
{ feature_id: setup.refs.features.unlimited_seats.id, unlimited: true }, { feature_id: features.unlimited_seats.id, unlimited: true },
], ],
remove_items: [ remove_items: [
{ feature_id: setup.refs.features.revision_history.id }, { feature_id: features.insight_reports.id },
{ feature_id: setup.refs.features.brand_controls.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, timeout: 150_000,
cases: [ cases: [
{ {
name: "adds only missing features and removes excluded ones", name: "diffs the listed set against the plan: adds missing, removes extras",
conversation: [ conversation: [
user({ user({ message: userPrompt }),
message: `attach the Scale plan to kp-customer-0099. They get:
- priority queue
- approval chains
- compliance controls
- hosted solution
- unlimited seats
`,
}),
user({ message: "Looks good, attach it." }), user({ message: "Looks good, attach it." }),
approve({ optional: false }), approve({ optional: false }),
], ],

View File

@@ -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: [],
},
],
});

View File

@@ -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"],
}),
],
},
],
});

View File

@@ -39,7 +39,7 @@ const createEvalMcpServer = () =>
version: "0.0.1", version: "0.0.1",
description: "Operate on Autumn customers, plans, and billing.", description: "Operate on Autumn customers, plans, and billing.",
instructions: 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, resources: evalResources,
tools: createRawAutumnOperationTools(), tools: createRawAutumnOperationTools(),
}); });

View File

@@ -83,7 +83,7 @@
"vite:build": "bun -F @autumn/vite build:bun", "vite:build": "bun -F @autumn/vite build:bun",
"t": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/testScripts/testDispatcher.ts", "t": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/testScripts/testDispatcher.ts",
"cm": "cd server && bun cm", "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": "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: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", "dw:list": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/dw/index.ts list",

View File

@@ -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. - 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. - Gather all remaining missing questions from the checklist and ask them together.
- If there are no missing questions, call the preview tool. - If there are no missing questions, call the preview tool.
- Surface the preview and ask for the user's feedback or approval. - Surface the preview's immediate billing impact, then obtain approval via your client's approval mechanism.
- If the user changes anything, update params and repeat from the relevant checklist step. - If params change, update them and repeat from the relevant checklist step.
- If the user approves the preview, execute the exact previewed billing action. - Once approved, apply the exact previewed billing action.
</workflow> </workflow>
<rules> <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. - 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. - 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`. - 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. - 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. - 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. - 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> </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. - 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. - 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`. - 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. - 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. - 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. - 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.

View File

@@ -3,14 +3,12 @@
- Use the `customize` object for customer-specific plan terms. - 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. - 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. - 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. - 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.
- PUT-style: `customize.items` replaces the full item set. - "Features" may mean only some items (e.g. booleans) or include credits/metered items; clarify scope before removing anything priced.
- PATCH-style: `customize.add_items` and `customize.remove_items` change selected items. - 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. - 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. - 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`. - 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. - 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", "customer_id": "cus_123",
"plan_id": "pro", "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: - Add a boolean feature:
```json ```json
{ "customize": { "add_items": [{ "feature_id": "sso", "unlimited": true }] } } { "customize": { "add_items": [{ "feature_id": "sso" }] } }
``` ```
- Remove a feature: - Remove a feature:

View File

@@ -9,8 +9,8 @@
<attach-timing> <attach-timing>
- If `plan_schedule` is omitted, downgrades are usually scheduled for end of cycle and the current plan transitions out then. - To attach now, explicitly set `plan_schedule: "immediate"`; omitting it can schedule a lower- or zero-base-price plan for end of cycle.
- Use `plan_schedule: "immediate"` when the user wants an immediate downgrade. - 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. - 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. - 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`. - 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`.

View File

@@ -29,9 +29,8 @@
<boolean> <boolean>
- Pass `feature_id`. - Pass only `feature_id`; set neither `included` nor `unlimited`. `feature_id` alone grants access.
- Grants access rather than quantity. - 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 plan items cannot be paid today; charge through `Plan.price` or another metered feature instead.
</boolean> </boolean>

View File

@@ -56,10 +56,9 @@
<trial-behavior> <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. - 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` — it 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`.
- The limited-time trial plan gives temporary access, expires automatically, and lets the user later enter the normal checkout flow for `pro`.
</trial-behavior> </trial-behavior>

View 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>

View File

@@ -34,6 +34,7 @@ const conceptResource = {
"./concepts/feature.md", "./concepts/feature.md",
"./concepts/plan.md", "./concepts/plan.md",
"./concepts/plan-item.md", "./concepts/plan-item.md",
"./concepts/trials.md",
"./concepts/customer-entity.md", "./concepts/customer-entity.md",
"./concepts/billing-controls.md", "./concepts/billing-controls.md",
], ],
@@ -177,7 +178,12 @@ export const createAutumnMcpResources = ({
baseUrl: string | URL; baseUrl: string | URL;
}): MCPServerResources => { }): MCPServerResources => {
let docs: AutumnMcpResourceDoc[] | undefined; 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 = () => { const getDocs = () => {
if (process.env.NODE_ENV !== "production") {
return compileResources({ baseUrl });
}
docs ??= compileResources({ baseUrl }); docs ??= compileResources({ baseUrl });
return docs; return docs;
}; };

View File

@@ -26,5 +26,5 @@ Always read the relevant Autumn MCP resources to understand Autumn before starti
## Writes ## Writes
- Use preview tools before billing 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. - If a preview fails, state the blocking reason once and stop; do not call or suggest the write tool.

View File

@@ -29,7 +29,7 @@
"parallel-tests:debug": "ENV_FILE=.env infisical run --env=dev --recursive -- bun tests/testRunner/runParallelGroups.ts --debug", "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", "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", "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", "ts": "bunx tsgo --build --noEmit",
"test:unit": "ENV_FILE=.env infisical run --env=dev --recursive -- bun test --isolate tests/unit", "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", "test:integration": "ENV_FILE=.env infisical run --env=dev --recursive -- bun test --timeout 0 --preload ./tests/setup-integration-tests.ts",

View File

@@ -4,27 +4,23 @@ import {
RecaseError, RecaseError,
} from "@autumn/shared"; } from "@autumn/shared";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
import { isDeferredInvoiceMode } from "@/internal/billing/v2/utils/billingContext/isDeferredInvoiceMode";
/** /**
* Validates invoice mode configuration against the attach context. * Validates invoice mode configuration against the attach context.
* *
* Throws error when: * Throws when deferred invoice-mode activation is used for a downgrade
* - Invoice mode with deferred activation (enableProductImmediately=false) is used for a downgrade * (planTiming="end_of_cycle"): there is no immediate invoice to pay, so deferral makes no sense.
* (planTiming="end_of_cycle"). Downgrades are scheduled for end of cycle and have no immediate
* invoice to pay, so deferred activation makes no sense.
*/ */
export const handleAttachInvoiceModeErrors = ({ export const handleAttachInvoiceModeErrors = ({
billingContext, billingContext,
}: { }: {
billingContext: AttachBillingContext; billingContext: AttachBillingContext;
}) => { }) => {
const { invoiceMode, planTiming } = billingContext; const { planTiming } = billingContext;
// Check: Invoice mode deferred + downgrade (scheduled plan) // Check: Invoice mode deferred + downgrade (scheduled plan)
if ( if (isDeferredInvoiceMode({ billingContext }) && planTiming === "end_of_cycle") {
invoiceMode?.enableProductImmediately === false &&
planTiming === "end_of_cycle"
) {
throw new RecaseError({ throw new RecaseError({
message: message:
"Cannot use invoice mode with deferred activation for downgrades. Downgrades are scheduled for end of cycle and have no immediate invoice to pay.", "Cannot use invoice mode with deferred activation for downgrades. Downgrades are scheduled for end of cycle and have no immediate invoice to pay.",

View File

@@ -15,7 +15,6 @@ import {
orgDisableStripeWrites, orgDisableStripeWrites,
orgToReturnUrl, orgToReturnUrl,
} from "@autumn/shared"; } from "@autumn/shared";
import { all } from "better-all";
import type { AutumnContext } from "@/honoUtils/HonoEnv"; import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext"; import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor"; import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
@@ -53,30 +52,25 @@ export const setupAttachBillingContext = async ({
}): Promise<AttachBillingContext> => { }): Promise<AttachBillingContext> => {
const { fullCustomer: fullCustomerOverride } = contextOverride; const { fullCustomer: fullCustomerOverride } = contextOverride;
// 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,
}));
const { const {
fullProduct: attachProduct,
customPrices,
customEnts,
} = await setupAttachProductContext({
ctx,
params,
contextOverride,
fullCustomer, fullCustomer,
attachProductContext: {
fullProduct: attachProduct,
customPrices,
customEnts,
},
} = await all({
async fullCustomer() {
return (
fullCustomerOverride ??
(await setupFullCustomerContext({
ctx,
params,
}))
);
},
async attachProductContext() {
return setupAttachProductContext({
ctx,
params,
contextOverride,
});
},
}); });
const { currentCustomerProduct, scheduledCustomerProduct, planTiming } = const { currentCustomerProduct, scheduledCustomerProduct, planTiming } =

View File

@@ -7,6 +7,9 @@ export const isDeferredInvoiceMode = ({
}): boolean => { }): boolean => {
const isInvoiceMode = Boolean(billingContext.invoiceMode); 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 = const shouldDefer =
billingContext.invoiceMode?.enableProductImmediately === false; billingContext.invoiceMode?.enableProductImmediately === false;

View File

@@ -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,
});
});

View File

@@ -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,
});
});

View 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);
});
}

View File

@@ -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. // Advanced 7 days into a 14-day trial: still trialing, ends in the future.
expect(subscription?.trial_ends_at).toBeGreaterThan(Date.now()); 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();
});

View File

@@ -32,7 +32,7 @@ import {
} from "../seedUtils"; } from "../seedUtils";
// ── Knowledge platform seed config ── // ── 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 = { const KP_SEED = {
customerCount: 0, // 0 = catalog only (features + plans, no customers) customerCount: 0, // 0 = catalog only (features + plans, no customers)
concurrency: 10, concurrency: 10,
@@ -503,7 +503,7 @@ const getArgValue = (name: string) => {
return index === -1 ? undefined : process.argv[index + 1]; return index === -1 ? undefined : process.argv[index + 1];
}; };
const runKnowledgePlatformSeed = async () => { export const runKnowledgePlatformSeed = async () => {
const customerCount = Number(getArgValue("--count") ?? KP_SEED.customerCount); const customerCount = Number(getArgValue("--count") ?? KP_SEED.customerCount);
const concurrency = Number(getArgValue("--concurrency") ?? KP_SEED.concurrency); const concurrency = Number(getArgValue("--concurrency") ?? KP_SEED.concurrency);
const attachPlan = (getArgValue("--attach-plan") ?? KP_SEED.attachPlan) as const attachPlan = (getArgValue("--attach-plan") ?? KP_SEED.attachPlan) as

View 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);
});