stagent 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +35 -4
  2. package/package.json +3 -2
  3. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  4. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  5. package/src/__tests__/e2e/helpers.ts +286 -0
  6. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  7. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  8. package/src/__tests__/e2e/setup.ts +156 -0
  9. package/src/__tests__/e2e/single-task.test.ts +170 -0
  10. package/src/app/api/command-palette/recent/route.ts +41 -18
  11. package/src/app/api/context/batch/route.ts +44 -0
  12. package/src/app/api/permissions/presets/route.ts +80 -0
  13. package/src/app/api/playbook/status/route.ts +15 -0
  14. package/src/app/api/profiles/route.ts +23 -20
  15. package/src/app/api/settings/pricing/route.ts +15 -0
  16. package/src/app/costs/page.tsx +53 -43
  17. package/src/app/playbook/[slug]/page.tsx +76 -0
  18. package/src/app/playbook/page.tsx +54 -0
  19. package/src/app/profiles/page.tsx +7 -4
  20. package/src/app/settings/page.tsx +2 -2
  21. package/src/components/costs/cost-dashboard.tsx +226 -320
  22. package/src/components/dashboard/activity-feed.tsx +6 -2
  23. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  24. package/src/components/notifications/notification-item.tsx +6 -3
  25. package/src/components/notifications/pending-approval-host.tsx +57 -11
  26. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  27. package/src/components/playbook/journey-card.tsx +110 -0
  28. package/src/components/playbook/playbook-action-button.tsx +22 -0
  29. package/src/components/playbook/playbook-browser.tsx +143 -0
  30. package/src/components/playbook/playbook-card.tsx +102 -0
  31. package/src/components/playbook/playbook-detail-view.tsx +223 -0
  32. package/src/components/playbook/playbook-homepage.tsx +142 -0
  33. package/src/components/playbook/playbook-toc.tsx +90 -0
  34. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  35. package/src/components/playbook/related-docs.tsx +30 -0
  36. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  37. package/src/components/profiles/context-proposal-review.tsx +7 -3
  38. package/src/components/profiles/learned-context-panel.tsx +116 -8
  39. package/src/components/profiles/profile-detail-view.tsx +6 -3
  40. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  41. package/src/components/settings/api-key-form.tsx +5 -43
  42. package/src/components/settings/auth-config-section.tsx +10 -6
  43. package/src/components/settings/auth-status-badge.tsx +8 -0
  44. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  45. package/src/components/settings/connection-test-control.tsx +63 -0
  46. package/src/components/settings/permissions-section.tsx +85 -75
  47. package/src/components/settings/permissions-sections.tsx +24 -0
  48. package/src/components/settings/presets-section.tsx +159 -0
  49. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  50. package/src/components/shared/app-sidebar.tsx +2 -0
  51. package/src/components/shared/command-palette.tsx +30 -0
  52. package/src/components/shared/light-markdown.tsx +134 -0
  53. package/src/components/workflows/loop-status-view.tsx +8 -4
  54. package/src/components/workflows/workflow-status-view.tsx +16 -9
  55. package/src/lib/agents/learned-context.ts +27 -15
  56. package/src/lib/agents/learning-session.ts +234 -0
  57. package/src/lib/agents/pattern-extractor.ts +19 -0
  58. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  59. package/src/lib/agents/profiles/sort.ts +7 -0
  60. package/src/lib/constants/settings.ts +1 -0
  61. package/src/lib/db/schema.ts +3 -0
  62. package/src/lib/docs/adoption.ts +105 -0
  63. package/src/lib/docs/journey-tracker.ts +21 -0
  64. package/src/lib/docs/reader.ts +102 -0
  65. package/src/lib/docs/types.ts +54 -0
  66. package/src/lib/docs/usage-stage.ts +60 -0
  67. package/src/lib/notifications/actionable.ts +18 -10
  68. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  69. package/src/lib/settings/budget-guardrails.ts +213 -85
  70. package/src/lib/settings/permission-presets.ts +150 -0
  71. package/src/lib/settings/runtime-setup.ts +71 -0
  72. package/src/lib/usage/__tests__/ledger.test.ts +2 -2
  73. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  74. package/src/lib/usage/ledger.ts +1 -1
  75. package/src/lib/usage/pricing-registry.ts +570 -0
  76. package/src/lib/usage/pricing.ts +15 -95
  77. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  78. package/src/lib/utils/learned-context-history.ts +150 -0
  79. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  80. package/src/lib/validators/settings.ts +3 -9
  81. package/src/lib/workflows/engine.ts +18 -0
@@ -0,0 +1,105 @@
1
+ import { db } from "@/lib/db";
2
+ import {
3
+ tasks,
4
+ workflows,
5
+ documents,
6
+ schedules,
7
+ usageLedger,
8
+ learnedContext,
9
+ settings,
10
+ } from "@/lib/db/schema";
11
+ import { count, like, sql } from "drizzle-orm";
12
+ import type { AdoptionEntry } from "./types";
13
+
14
+ function toDepth(n: number): AdoptionEntry["depth"] {
15
+ if (n === 0) return "none";
16
+ if (n <= 3) return "light";
17
+ return "deep";
18
+ }
19
+
20
+ function entry(n: number): AdoptionEntry {
21
+ return { adopted: n > 0, depth: toDepth(n) };
22
+ }
23
+
24
+ /** Map each manifest section slug to DB evidence of adoption */
25
+ export async function getAdoptionMap(): Promise<Map<string, AdoptionEntry>> {
26
+ const [
27
+ taskCount,
28
+ workflowCount,
29
+ documentCount,
30
+ scheduleCount,
31
+ profileUsed,
32
+ usageCount,
33
+ learnedCount,
34
+ permissionCount,
35
+ providerCount,
36
+ ] = await Promise.all([
37
+ db
38
+ .select({ n: count() })
39
+ .from(tasks)
40
+ .then((r) => r[0]?.n ?? 0),
41
+ db
42
+ .select({ n: count() })
43
+ .from(workflows)
44
+ .then((r) => r[0]?.n ?? 0),
45
+ db
46
+ .select({ n: count() })
47
+ .from(documents)
48
+ .then((r) => r[0]?.n ?? 0),
49
+ db
50
+ .select({ n: count() })
51
+ .from(schedules)
52
+ .then((r) => r[0]?.n ?? 0),
53
+ db
54
+ .select({ n: count() })
55
+ .from(tasks)
56
+ .where(sql`${tasks.agentProfile} IS NOT NULL`)
57
+ .then((r) => r[0]?.n ?? 0),
58
+ db
59
+ .select({ n: count() })
60
+ .from(usageLedger)
61
+ .then((r) => r[0]?.n ?? 0),
62
+ db
63
+ .select({ n: count() })
64
+ .from(learnedContext)
65
+ .then((r) => r[0]?.n ?? 0),
66
+ db
67
+ .select({ n: count() })
68
+ .from(settings)
69
+ .where(like(settings.key, "permission:%"))
70
+ .then((r) => r[0]?.n ?? 0),
71
+ db
72
+ .select({ n: sql<number>`COUNT(DISTINCT ${usageLedger.providerId})` })
73
+ .from(usageLedger)
74
+ .then((r) => Number(r[0]?.n ?? 0)),
75
+ ]);
76
+
77
+ const map = new Map<string, AdoptionEntry>();
78
+
79
+ // Always adopted
80
+ map.set("home-workspace", { adopted: true, depth: "deep" });
81
+ map.set("settings", { adopted: true, depth: "deep" });
82
+
83
+ // Feature sections
84
+ map.set("dashboard-kanban", entry(taskCount));
85
+ map.set("inbox-notifications", entry(taskCount)); // notifications come with tasks
86
+ map.set("monitoring", entry(taskCount));
87
+ map.set("projects", entry(taskCount)); // projects enable tasks
88
+ map.set("workflows", entry(workflowCount));
89
+ map.set("documents", entry(documentCount));
90
+ map.set("profiles", entry(profileUsed));
91
+ map.set("schedules", entry(scheduleCount));
92
+ map.set("cost-usage", entry(usageCount));
93
+
94
+ // Cross-cutting
95
+ map.set(
96
+ "provider-runtimes",
97
+ providerCount > 1
98
+ ? { adopted: true, depth: "deep" }
99
+ : entry(usageCount > 0 ? 1 : 0)
100
+ );
101
+ map.set("agent-intelligence", entry(learnedCount));
102
+ map.set("tool-permissions", entry(permissionCount));
103
+
104
+ return map;
105
+ }
@@ -0,0 +1,21 @@
1
+ import type { AdoptionEntry, DocJourney, JourneyCompletion } from "./types";
2
+
3
+ /** Compute journey completion from adoption data */
4
+ export function getJourneyCompletions(
5
+ journeys: DocJourney[],
6
+ adoptionMap: Map<string, AdoptionEntry>
7
+ ): Map<string, JourneyCompletion> {
8
+ const completions = new Map<string, JourneyCompletion>();
9
+
10
+ for (const journey of journeys) {
11
+ const total = journey.sections.length;
12
+ const completed = journey.sections.filter(
13
+ (slug) => adoptionMap.get(slug)?.adopted === true
14
+ ).length;
15
+ const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
16
+
17
+ completions.set(journey.slug, { completed, total, percentage });
18
+ }
19
+
20
+ return completions;
21
+ }
@@ -0,0 +1,102 @@
1
+ import { readFileSync, readdirSync, existsSync } from "fs";
2
+ import { join, basename } from "path";
3
+ import type { DocManifest, ParsedDoc } from "./types";
4
+
5
+ /** Resolve the docs directory relative to project root */
6
+ function docsDir(): string {
7
+ return join(process.cwd(), "docs");
8
+ }
9
+
10
+ /** Read and parse docs/manifest.json */
11
+ export function getManifest(): DocManifest {
12
+ const raw = readFileSync(join(docsDir(), "manifest.json"), "utf-8");
13
+ return JSON.parse(raw) as DocManifest;
14
+ }
15
+
16
+ /** Parse a markdown file into frontmatter + body */
17
+ function parseMarkdown(content: string, slug: string): ParsedDoc {
18
+ const fmRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
19
+ const match = content.match(fmRegex);
20
+
21
+ if (!match) {
22
+ return { frontmatter: {}, body: content, slug };
23
+ }
24
+
25
+ const frontmatter: Record<string, unknown> = {};
26
+ const fmLines = match[1].split("\n");
27
+ for (const line of fmLines) {
28
+ const colonIdx = line.indexOf(":");
29
+ if (colonIdx === -1) continue;
30
+ const key = line.slice(0, colonIdx).trim();
31
+ let value: unknown = line.slice(colonIdx + 1).trim();
32
+
33
+ // Strip surrounding quotes
34
+ if (
35
+ typeof value === "string" &&
36
+ value.startsWith('"') &&
37
+ value.endsWith('"')
38
+ ) {
39
+ value = value.slice(1, -1);
40
+ }
41
+
42
+ // Parse arrays: ["a", "b"]
43
+ if (typeof value === "string" && value.startsWith("[")) {
44
+ try {
45
+ value = JSON.parse(value);
46
+ } catch {
47
+ // keep as string
48
+ }
49
+ }
50
+
51
+ frontmatter[key] = value;
52
+ }
53
+
54
+ return { frontmatter, body: match[2], slug };
55
+ }
56
+
57
+ /** Read a single doc by slug (searches features/ then journeys/ then root) */
58
+ export function getDocBySlug(slug: string): ParsedDoc | null {
59
+ const dirs = ["features", "journeys", ""];
60
+ for (const dir of dirs) {
61
+ const filePath = join(docsDir(), dir, `${slug}.md`);
62
+ if (existsSync(filePath)) {
63
+ const content = readFileSync(filePath, "utf-8");
64
+ return parseMarkdown(content, slug);
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** Read all docs from features/ and journeys/ */
71
+ export function getAllDocs(): ParsedDoc[] {
72
+ const docs: ParsedDoc[] = [];
73
+ const subDirs = ["features", "journeys"];
74
+
75
+ for (const dir of subDirs) {
76
+ const dirPath = join(docsDir(), dir);
77
+ if (!existsSync(dirPath)) continue;
78
+
79
+ const files = readdirSync(dirPath).filter((f) => f.endsWith(".md"));
80
+ for (const file of files) {
81
+ const content = readFileSync(join(dirPath, file), "utf-8");
82
+ const slug = basename(file, ".md");
83
+ docs.push(parseMarkdown(content, slug));
84
+ }
85
+ }
86
+
87
+ // Also check for getting-started.md at root
88
+ const gsPath = join(docsDir(), "getting-started.md");
89
+ if (existsSync(gsPath)) {
90
+ const content = readFileSync(gsPath, "utf-8");
91
+ docs.push(parseMarkdown(content, "getting-started"));
92
+ }
93
+
94
+ return docs;
95
+ }
96
+
97
+ /** Read the .last-generated timestamp */
98
+ export function getDocsLastGenerated(): string | null {
99
+ const filePath = join(docsDir(), ".last-generated");
100
+ if (!existsSync(filePath)) return null;
101
+ return readFileSync(filePath, "utf-8").trim();
102
+ }
@@ -0,0 +1,54 @@
1
+ // Types for the Playbook documentation system
2
+
3
+ export interface DocManifest {
4
+ generated: string;
5
+ version: number;
6
+ sections: DocSection[];
7
+ journeys: DocJourney[];
8
+ metadata: {
9
+ totalDocs: number;
10
+ totalScreengrabs: number;
11
+ featuresCovered: number;
12
+ appSections: number;
13
+ };
14
+ }
15
+
16
+ export interface DocSection {
17
+ slug: string;
18
+ title: string;
19
+ category: string;
20
+ path: string;
21
+ route: string;
22
+ tags: string[];
23
+ features: string[];
24
+ screengrabCount: number;
25
+ }
26
+
27
+ export interface DocJourney {
28
+ slug: string;
29
+ title: string;
30
+ persona: string;
31
+ difficulty: string;
32
+ path: string;
33
+ sections: string[];
34
+ stepCount: number;
35
+ }
36
+
37
+ export interface ParsedDoc {
38
+ frontmatter: Record<string, unknown>;
39
+ body: string;
40
+ slug: string;
41
+ }
42
+
43
+ export type UsageStage = "new" | "early" | "active" | "power";
44
+
45
+ export interface AdoptionEntry {
46
+ adopted: boolean;
47
+ depth: "none" | "light" | "deep";
48
+ }
49
+
50
+ export interface JourneyCompletion {
51
+ completed: number;
52
+ total: number;
53
+ percentage: number;
54
+ }
@@ -0,0 +1,60 @@
1
+ import { db } from "@/lib/db";
2
+ import {
3
+ tasks,
4
+ projects,
5
+ workflows,
6
+ schedules,
7
+ usageLedger,
8
+ } from "@/lib/db/schema";
9
+ import { count, isNotNull } from "drizzle-orm";
10
+ import type { UsageStage } from "./types";
11
+
12
+ /** Compute the user's usage stage from DB state */
13
+ export async function getUsageStage(): Promise<UsageStage> {
14
+ const [taskCount, projectCount, workflowCount, scheduleCount, profileUsed] =
15
+ await Promise.all([
16
+ db
17
+ .select({ n: count() })
18
+ .from(tasks)
19
+ .then((r) => r[0]?.n ?? 0),
20
+ db
21
+ .select({ n: count() })
22
+ .from(projects)
23
+ .then((r) => r[0]?.n ?? 0),
24
+ db
25
+ .select({ n: count() })
26
+ .from(workflows)
27
+ .then((r) => r[0]?.n ?? 0),
28
+ db
29
+ .select({ n: count() })
30
+ .from(schedules)
31
+ .then((r) => r[0]?.n ?? 0),
32
+ db
33
+ .select({ n: count() })
34
+ .from(tasks)
35
+ .where(isNotNull(tasks.agentProfile))
36
+ .then((r) => r[0]?.n ?? 0),
37
+ ]);
38
+
39
+ // Power: has workflows + schedules + profiles
40
+ if (workflowCount > 0 && scheduleCount > 0 && profileUsed > 0) {
41
+ return "power";
42
+ }
43
+
44
+ // Active: 6+ tasks OR 3+ projects OR any workflows/schedules
45
+ if (
46
+ taskCount >= 6 ||
47
+ projectCount >= 3 ||
48
+ workflowCount > 0 ||
49
+ scheduleCount > 0
50
+ ) {
51
+ return "active";
52
+ }
53
+
54
+ // Early: 1-5 tasks, <=2 projects
55
+ if (taskCount >= 1 || projectCount >= 1) {
56
+ return "early";
57
+ }
58
+
59
+ return "new";
60
+ }
@@ -80,6 +80,7 @@ export async function listPendingApprovalPayloads(
80
80
  inArray(notifications.type, [
81
81
  "permission_required",
82
82
  "context_proposal",
83
+ "context_proposal_batch",
83
84
  ]),
84
85
  isNull(notifications.response)
85
86
  )
@@ -89,6 +90,7 @@ export async function listPendingApprovalPayloads(
89
90
 
90
91
  return rows.map((row) => {
91
92
  const isContextProposal = row.type === "context_proposal";
93
+ const isBatchProposal = row.type === "context_proposal_batch";
92
94
  const parsedInput = parseNotificationToolInput(row.toolInput);
93
95
 
94
96
  return {
@@ -97,16 +99,22 @@ export async function listPendingApprovalPayloads(
97
99
  taskId: row.taskId,
98
100
  workflowId: row.workflowId,
99
101
  toolName: row.toolName,
100
- permissionLabel: isContextProposal
101
- ? "Context Proposal"
102
- : getPermissionKindLabel(row.toolName),
103
- compactSummary: isContextProposal
104
- ? `Learned patterns proposed for profile "${row.toolName}"`
105
- : buildPermissionSummary(row.toolName, parsedInput),
106
- deepLink: isContextProposal
107
- ? `/profiles/${row.toolName}`
108
- : buildDeepLink(row.taskId, row.workflowId),
109
- supportedActionIds: isContextProposal
102
+ permissionLabel: isBatchProposal
103
+ ? "Workflow Learning"
104
+ : isContextProposal
105
+ ? "Context Proposal"
106
+ : getPermissionKindLabel(row.toolName),
107
+ compactSummary: isBatchProposal
108
+ ? `Batch of learned patterns from workflow execution`
109
+ : isContextProposal
110
+ ? `Learned patterns proposed for profile "${row.toolName}"`
111
+ : buildPermissionSummary(row.toolName, parsedInput),
112
+ deepLink: isBatchProposal
113
+ ? "/inbox"
114
+ : isContextProposal
115
+ ? `/profiles/${row.toolName}`
116
+ : buildDeepLink(row.taskId, row.workflowId),
117
+ supportedActionIds: (isContextProposal || isBatchProposal)
110
118
  ? (["allow_once", "deny"] as ApprovalActionId[])
111
119
  : [...APPROVAL_ACTION_IDS],
112
120
  title: row.title,
@@ -8,10 +8,13 @@ let tempDir: string;
8
8
  beforeEach(() => {
9
9
  tempDir = mkdtempSync(join(tmpdir(), "stagent-budget-guardrails-"));
10
10
  vi.resetModules();
11
+ vi.useFakeTimers();
12
+ vi.setSystemTime(new Date("2026-03-17T12:00:00.000Z"));
11
13
  vi.stubEnv("STAGENT_DATA_DIR", tempDir);
12
14
  });
13
15
 
14
16
  afterEach(() => {
17
+ vi.useRealTimers();
15
18
  vi.unstubAllEnvs();
16
19
  rmSync(tempDir, { recursive: true, force: true });
17
20
  });
@@ -21,8 +24,9 @@ async function loadModules() {
21
24
  const schema = await import("@/lib/db/schema");
22
25
  const ledger = await import("@/lib/usage/ledger");
23
26
  const budgets = await import("../budget-guardrails");
27
+ const auth = await import("../auth");
24
28
 
25
- return { db, ...schema, ...ledger, ...budgets };
29
+ return { db, ...schema, ...ledger, ...budgets, ...auth };
26
30
  }
27
31
 
28
32
  describe("budget guardrails", () => {
@@ -33,25 +37,22 @@ describe("budget guardrails", () => {
33
37
  recordUsageLedgerEntry,
34
38
  setBudgetPolicy,
35
39
  enforceBudgetGuardrails,
40
+ setAuthSettings,
36
41
  } = await loadModules();
37
42
 
43
+ vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
44
+ await setAuthSettings({ method: "api_key" });
45
+
38
46
  await setBudgetPolicy({
39
47
  overall: {
40
- dailySpendCapUsd: 0.012,
41
- monthlySpendCapUsd: null,
48
+ monthlySpendCapUsd: 0.36,
42
49
  },
43
50
  runtimes: {
44
51
  "claude-code": {
45
- dailySpendCapUsd: null,
46
- monthlySpendCapUsd: null,
47
- dailyTokenCap: null,
48
- monthlyTokenCap: null,
52
+ monthlySpendCapUsd: 0.36,
49
53
  },
50
54
  "openai-codex-app-server": {
51
- dailySpendCapUsd: null,
52
55
  monthlySpendCapUsd: null,
53
- dailyTokenCap: null,
54
- monthlyTokenCap: null,
55
56
  },
56
57
  },
57
58
  });
@@ -80,9 +81,10 @@ describe("budget guardrails", () => {
80
81
  });
81
82
 
82
83
  const rows = await db.select().from(notifications);
83
- expect(rows).toHaveLength(1);
84
- expect(rows[0]?.type).toBe("budget_alert");
85
- expect(rows[0]?.title).toContain("Overall daily spend");
84
+ expect(rows).toHaveLength(2);
85
+ expect(rows.every((row) => row.type === "budget_alert")).toBe(true);
86
+ expect(rows.some((row) => row.title.includes("Overall daily spend"))).toBe(true);
87
+ expect(rows.some((row) => row.title.includes("Claude Code daily spend"))).toBe(true);
86
88
  });
87
89
 
88
90
  it("blocks new runtime activity, records a zero-cost ledger row, and fails queued tasks when requested", async () => {
@@ -95,8 +97,12 @@ describe("budget guardrails", () => {
95
97
  setBudgetPolicy,
96
98
  enforceTaskBudgetGuardrails,
97
99
  BudgetLimitExceededError,
100
+ setAuthSettings,
98
101
  } = await loadModules();
99
102
 
103
+ vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
104
+ await setAuthSettings({ method: "api_key" });
105
+
100
106
  const taskId = crypto.randomUUID();
101
107
  const now = new Date();
102
108
 
@@ -120,21 +126,14 @@ describe("budget guardrails", () => {
120
126
 
121
127
  await setBudgetPolicy({
122
128
  overall: {
123
- dailySpendCapUsd: null,
124
- monthlySpendCapUsd: null,
129
+ monthlySpendCapUsd: 0.001,
125
130
  },
126
131
  runtimes: {
127
132
  "claude-code": {
128
- dailySpendCapUsd: null,
129
- monthlySpendCapUsd: null,
130
- dailyTokenCap: 100,
131
- monthlyTokenCap: null,
133
+ monthlySpendCapUsd: 0.001,
132
134
  },
133
135
  "openai-codex-app-server": {
134
- dailySpendCapUsd: null,
135
136
  monthlySpendCapUsd: null,
136
- dailyTokenCap: null,
137
- monthlyTokenCap: null,
138
137
  },
139
138
  },
140
139
  });
@@ -159,7 +158,7 @@ describe("budget guardrails", () => {
159
158
 
160
159
  const [task] = await db.select().from(tasks);
161
160
  expect(task?.status).toBe("failed");
162
- expect(task?.result).toContain("token usage");
161
+ expect(task?.result).toContain("spend");
163
162
 
164
163
  const ledgerRows = await db.select().from(usageLedger);
165
164
  const blockedRow = ledgerRows.find((row) => row.status === "blocked");
@@ -176,6 +175,69 @@ describe("budget guardrails", () => {
176
175
 
177
176
  const budgetNotifications = await db.select().from(notifications);
178
177
  expect(budgetNotifications.at(-1)?.title).toContain("blocked");
179
- expect(budgetNotifications.at(-1)?.body).toContain("Resets");
178
+ expect(budgetNotifications.at(-1)?.body).toContain("spend");
179
+ });
180
+
181
+ it("derives daily spend caps from the monthly cap and auto-assigns a single configured runtime", async () => {
182
+ const { setBudgetPolicy, getBudgetGuardrailSnapshot, setAuthSettings } =
183
+ await loadModules();
184
+
185
+ vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
186
+ vi.stubEnv("OPENAI_API_KEY", "");
187
+ await setAuthSettings({ method: "api_key" });
188
+
189
+ await setBudgetPolicy({
190
+ overall: {
191
+ monthlySpendCapUsd: 310,
192
+ },
193
+ runtimes: {
194
+ "claude-code": {
195
+ monthlySpendCapUsd: 155,
196
+ },
197
+ "openai-codex-app-server": {
198
+ monthlySpendCapUsd: 155,
199
+ },
200
+ },
201
+ });
202
+
203
+ const snapshot = await getBudgetGuardrailSnapshot();
204
+ const overallDaily = snapshot.statuses.find(
205
+ (status) => status.scopeId === "overall" && status.window === "daily"
206
+ );
207
+ const claudeMonthly = snapshot.policy.runtimes["claude-code"].monthlySpendCapUsd;
208
+ const openAIMonthly =
209
+ snapshot.policy.runtimes["openai-codex-app-server"].monthlySpendCapUsd;
210
+
211
+ expect(overallDaily?.limitValue).toBe(10_000_000);
212
+ expect(claudeMonthly).toBe(310);
213
+ expect(openAIMonthly).toBeNull();
214
+ });
215
+
216
+ it("splits configured provider caps from the overall budget when both runtimes are configured", async () => {
217
+ vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
218
+ vi.stubEnv("OPENAI_API_KEY", "sk-test-openai");
219
+ const { setBudgetPolicy, getBudgetGuardrailSnapshot, setAuthSettings } =
220
+ await loadModules();
221
+ await setAuthSettings({ method: "api_key" });
222
+
223
+ await setBudgetPolicy({
224
+ overall: {
225
+ monthlySpendCapUsd: 300,
226
+ },
227
+ runtimes: {
228
+ "claude-code": {
229
+ monthlySpendCapUsd: 120,
230
+ },
231
+ "openai-codex-app-server": {
232
+ monthlySpendCapUsd: 180,
233
+ },
234
+ },
235
+ });
236
+
237
+ const snapshot = await getBudgetGuardrailSnapshot();
238
+ expect(snapshot.policy.runtimes["claude-code"].monthlySpendCapUsd).toBe(120);
239
+ expect(snapshot.policy.runtimes["openai-codex-app-server"].monthlySpendCapUsd).toBe(
240
+ 180
241
+ );
180
242
  });
181
243
  });