stagent 0.1.0 → 0.1.2

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 (64) hide show
  1. package/README.md +33 -30
  2. package/dist/cli.js +376 -49
  3. package/package.json +23 -24
  4. package/public/desktop-icon-512.png +0 -0
  5. package/public/icon-512.png +0 -0
  6. package/src/app/api/data/clear/route.ts +0 -7
  7. package/src/app/api/data/seed/route.ts +0 -7
  8. package/src/app/api/profiles/[id]/context/route.ts +109 -0
  9. package/src/components/dashboard/__tests__/accessibility.test.tsx +42 -0
  10. package/src/components/documents/__tests__/document-upload-dialog.test.tsx +46 -0
  11. package/src/components/notifications/__tests__/pending-approval-host.test.tsx +122 -0
  12. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +79 -0
  13. package/src/components/notifications/pending-approval-host.tsx +49 -25
  14. package/src/components/profiles/context-proposal-review.tsx +145 -0
  15. package/src/components/profiles/learned-context-panel.tsx +286 -0
  16. package/src/components/profiles/profile-detail-view.tsx +4 -0
  17. package/src/components/projects/__tests__/dialog-focus.test.tsx +87 -0
  18. package/src/components/tasks/__tests__/kanban-board-accessibility.test.tsx +59 -0
  19. package/src/lib/__tests__/setup-verify.test.ts +28 -0
  20. package/src/lib/__tests__/utils.test.ts +29 -0
  21. package/src/lib/agents/__tests__/claude-agent.test.ts +946 -0
  22. package/src/lib/agents/__tests__/execution-manager.test.ts +63 -0
  23. package/src/lib/agents/__tests__/router.test.ts +61 -0
  24. package/src/lib/agents/claude-agent.ts +34 -5
  25. package/src/lib/agents/learned-context.ts +322 -0
  26. package/src/lib/agents/pattern-extractor.ts +150 -0
  27. package/src/lib/agents/profiles/__tests__/compatibility.test.ts +76 -0
  28. package/src/lib/agents/profiles/__tests__/registry.test.ts +177 -0
  29. package/src/lib/agents/profiles/builtins/sweep/SKILL.md +47 -0
  30. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +12 -0
  31. package/src/lib/agents/runtime/__tests__/catalog.test.ts +38 -0
  32. package/src/lib/agents/runtime/openai-codex.ts +1 -1
  33. package/src/lib/agents/sweep.ts +65 -0
  34. package/src/lib/constants/__tests__/task-status.test.ts +119 -0
  35. package/src/lib/data/seed-data/__tests__/profiles.test.ts +141 -0
  36. package/src/lib/db/__tests__/bootstrap.test.ts +56 -0
  37. package/src/lib/db/bootstrap.ts +301 -0
  38. package/src/lib/db/index.ts +2 -205
  39. package/src/lib/db/migrations/0004_add_documents.sql +2 -1
  40. package/src/lib/db/migrations/0005_add_document_preprocessing.sql +2 -0
  41. package/src/lib/db/migrations/0006_add_agent_profile.sql +1 -0
  42. package/src/lib/db/migrations/0007_add_usage_metering_ledger.sql +9 -2
  43. package/src/lib/db/migrations/meta/_journal.json +43 -1
  44. package/src/lib/db/schema.ts +34 -0
  45. package/src/lib/desktop/__tests__/sidecar-launch.test.ts +70 -0
  46. package/src/lib/desktop/sidecar-launch.ts +85 -0
  47. package/src/lib/documents/__tests__/context-builder.test.ts +57 -0
  48. package/src/lib/documents/__tests__/output-scanner.test.ts +141 -0
  49. package/src/lib/notifications/actionable.ts +21 -7
  50. package/src/lib/settings/__tests__/auth.test.ts +220 -0
  51. package/src/lib/settings/__tests__/budget-guardrails.test.ts +181 -0
  52. package/src/lib/tauri-bridge.ts +138 -0
  53. package/src/lib/usage/__tests__/ledger.test.ts +284 -0
  54. package/src/lib/utils/__tests__/crypto.test.ts +90 -0
  55. package/src/lib/validators/__tests__/profile.test.ts +119 -0
  56. package/src/lib/validators/__tests__/project.test.ts +82 -0
  57. package/src/lib/validators/__tests__/settings.test.ts +151 -0
  58. package/src/lib/validators/__tests__/task.test.ts +144 -0
  59. package/src/lib/workflows/__tests__/definition-validation.test.ts +164 -0
  60. package/src/lib/workflows/__tests__/engine.test.ts +114 -0
  61. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  62. package/src/lib/workflows/__tests__/parallel.test.ts +75 -0
  63. package/src/lib/workflows/__tests__/swarm.test.ts +97 -0
  64. package/src/test/setup.ts +10 -0
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ getProfileRuntimeCompatibility,
4
+ getSupportedRuntimes,
5
+ profileSupportsRuntime,
6
+ resolveProfileRuntimePayload,
7
+ } from "../compatibility";
8
+
9
+ const baseProfile = {
10
+ id: "general",
11
+ name: "General",
12
+ skillMd: "# Shared instructions",
13
+ supportedRuntimes: ["claude-code", "openai-codex-app-server"] as const,
14
+ allowedTools: ["Read"],
15
+ tests: [
16
+ {
17
+ task: "Summarize the task",
18
+ expectedKeywords: ["summary"],
19
+ },
20
+ ],
21
+ };
22
+
23
+ describe("profile compatibility helpers", () => {
24
+ it("defaults missing runtime coverage to Claude Code", () => {
25
+ expect(getSupportedRuntimes({})).toEqual(["claude-code"]);
26
+ });
27
+
28
+ it("reports supported runtimes explicitly", () => {
29
+ expect(getSupportedRuntimes(baseProfile)).toEqual([
30
+ "claude-code",
31
+ "openai-codex-app-server",
32
+ ]);
33
+ });
34
+
35
+ it("detects unsupported runtime assignments", () => {
36
+ expect(
37
+ profileSupportsRuntime(
38
+ { ...baseProfile, supportedRuntimes: ["claude-code"] },
39
+ "openai-codex-app-server"
40
+ )
41
+ ).toBe(false);
42
+ });
43
+
44
+ it("resolves runtime-specific instruction overrides", () => {
45
+ const payload = resolveProfileRuntimePayload(
46
+ {
47
+ ...baseProfile,
48
+ runtimeOverrides: {
49
+ "openai-codex-app-server": {
50
+ instructions: "# Codex instructions",
51
+ allowedTools: ["Read", "Bash"],
52
+ },
53
+ },
54
+ },
55
+ "openai-codex-app-server"
56
+ );
57
+
58
+ expect(payload.supported).toBe(true);
59
+ expect(payload.instructions).toBe("# Codex instructions");
60
+ expect(payload.instructionsSource).toBe("runtime-override");
61
+ expect(payload.allowedTools).toEqual(["Read", "Bash"]);
62
+ });
63
+
64
+ it("returns an unsupported compatibility summary when runtime is blocked", () => {
65
+ const compatibility = getProfileRuntimeCompatibility(
66
+ {
67
+ ...baseProfile,
68
+ supportedRuntimes: ["claude-code"],
69
+ },
70
+ "openai-codex-app-server"
71
+ );
72
+
73
+ expect(compatibility.supported).toBe(false);
74
+ expect(compatibility.reason).toContain("does not support");
75
+ });
76
+ });
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import yaml from "js-yaml";
6
+
7
+ // We test the registry by mocking fs to avoid touching the real filesystem.
8
+ // The module under test uses import.meta.dirname so we mock that via the
9
+ // builtins directory path.
10
+
11
+ const MOCK_SKILLS_DIR = path.join(
12
+ process.env.HOME ?? ".",
13
+ ".claude",
14
+ "skills"
15
+ );
16
+
17
+ describe("profile registry", () => {
18
+ // We need to dynamically import registry after mocking
19
+ let getProfile: typeof import("../registry").getProfile;
20
+ let listProfiles: typeof import("../registry").listProfiles;
21
+ let getProfileTags: typeof import("../registry").getProfileTags;
22
+ let reloadProfiles: typeof import("../registry").reloadProfiles;
23
+ let isBuiltin: typeof import("../registry").isBuiltin;
24
+
25
+ beforeEach(async () => {
26
+ // Reset module cache so each test gets a fresh registry
27
+ vi.resetModules();
28
+ const mod = await import("../registry");
29
+ getProfile = mod.getProfile;
30
+ listProfiles = mod.listProfiles;
31
+ getProfileTags = mod.getProfileTags;
32
+ reloadProfiles = mod.reloadProfiles;
33
+ isBuiltin = mod.isBuiltin;
34
+ });
35
+
36
+ afterEach(() => {
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ it("loads builtin profiles from .claude/skills/", () => {
41
+ // The registry should find profiles copied by ensureBuiltins
42
+ const profiles = listProfiles();
43
+ expect(profiles.length).toBeGreaterThanOrEqual(1);
44
+
45
+ // Verify the general profile exists (it's always a builtin)
46
+ const general = getProfile("general");
47
+ expect(general).toBeDefined();
48
+ expect(general!.name).toBe("General");
49
+ expect(general!.domain).toBe("work");
50
+ });
51
+
52
+ it("returns all 14 builtin profiles", () => {
53
+ const profiles = listProfiles().filter((p) => isBuiltin(p.id));
54
+ const ids = profiles.map((p) => p.id);
55
+
56
+ expect(ids).toContain("general");
57
+ expect(ids).toContain("code-reviewer");
58
+ expect(ids).toContain("researcher");
59
+ expect(ids).toContain("document-writer");
60
+ expect(ids).toContain("project-manager");
61
+ expect(ids).toContain("data-analyst");
62
+ expect(ids).toContain("wealth-manager");
63
+ expect(ids).toContain("travel-planner");
64
+ expect(ids).toContain("technical-writer");
65
+ expect(ids).toContain("devops-engineer");
66
+ expect(ids).toContain("health-fitness-coach");
67
+ expect(ids).toContain("shopping-assistant");
68
+ expect(ids).toContain("learning-coach");
69
+ expect(ids).toContain("sweep");
70
+ expect(profiles.length).toBe(14);
71
+ });
72
+
73
+ it("getProfile returns undefined for unknown id", () => {
74
+ expect(getProfile("nonexistent")).toBeUndefined();
75
+ });
76
+
77
+ it("profiles have skillMd content from SKILL.md", () => {
78
+ const codeReviewer = getProfile("code-reviewer");
79
+ expect(codeReviewer).toBeDefined();
80
+ expect(codeReviewer!.skillMd).toContain("code reviewer");
81
+ expect(codeReviewer!.skillMd.length).toBeGreaterThan(50);
82
+ });
83
+
84
+ it("profiles have canUseToolPolicy from profile.yaml", () => {
85
+ const codeReviewer = getProfile("code-reviewer");
86
+ expect(codeReviewer?.canUseToolPolicy).toBeDefined();
87
+ expect(codeReviewer!.canUseToolPolicy!.autoApprove).toContain("Read");
88
+ expect(codeReviewer!.canUseToolPolicy!.autoApprove).toContain("Grep");
89
+ });
90
+
91
+ it("getProfileTags returns tag map", () => {
92
+ const tagMap = getProfileTags();
93
+ expect(tagMap.get("researcher")).toContain("research");
94
+ expect(tagMap.get("wealth-manager")).toContain("finance");
95
+ });
96
+
97
+ it("systemPrompt is set to skillMd for backward compat", () => {
98
+ const general = getProfile("general");
99
+ expect(general).toBeDefined();
100
+ expect(general!.systemPrompt).toBe(general!.skillMd);
101
+ });
102
+
103
+ it("reloadProfiles clears cache and re-scans", () => {
104
+ const before = listProfiles().length;
105
+ reloadProfiles();
106
+ const after = listProfiles().length;
107
+ // After reload, should still find the same profiles
108
+ expect(after).toBe(before);
109
+ });
110
+
111
+ it("profiles extract description from SKILL.md frontmatter", () => {
112
+ const researcher = getProfile("researcher");
113
+ expect(researcher).toBeDefined();
114
+ expect(researcher!.description).toContain("research");
115
+ });
116
+
117
+ it("profiles have correct domain values", () => {
118
+ const builtinProfiles = listProfiles().filter((p) => isBuiltin(p.id));
119
+ const workProfiles = builtinProfiles.filter((p) => p.domain === "work");
120
+ const personalProfiles = builtinProfiles.filter(
121
+ (p) => p.domain === "personal"
122
+ );
123
+
124
+ expect(workProfiles.length).toBe(9); // general, code-reviewer, researcher, document-writer, project-manager, data-analyst, technical-writer, devops-engineer, sweep
125
+ expect(personalProfiles.length).toBe(5); // wealth-manager, travel-planner, health-fitness-coach, shopping-assistant, learning-coach
126
+ });
127
+
128
+ it("detects a newly added on-disk profile after the cache is warm", async () => {
129
+ const originalHome = process.env.HOME;
130
+ const tempHome = fs.mkdtempSync(
131
+ path.join(os.tmpdir(), "registry-cache-regression-")
132
+ );
133
+
134
+ try {
135
+ process.env.HOME = tempHome;
136
+ vi.resetModules();
137
+
138
+ const registry = await import("../registry");
139
+ const warmProfiles = registry.listProfiles();
140
+ const initialCount = warmProfiles.length;
141
+ const profileId = `registry-cache-regression-${Date.now()}`;
142
+ const profileDir = path.join(tempHome, ".claude", "skills", profileId);
143
+
144
+ fs.mkdirSync(profileDir, { recursive: true });
145
+ fs.writeFileSync(
146
+ path.join(profileDir, "profile.yaml"),
147
+ yaml.dump({
148
+ id: profileId,
149
+ name: "Registry Cache Regression",
150
+ version: "1.0.0",
151
+ domain: "work",
152
+ tags: ["regression", "cache"],
153
+ })
154
+ );
155
+ fs.writeFileSync(
156
+ path.join(profileDir, "SKILL.md"),
157
+ `---
158
+ name: ${profileId}
159
+ description: Profile added after the registry cache is warm.
160
+ ---
161
+
162
+ This profile exists to verify automatic cache refresh for on-disk changes.
163
+ `
164
+ );
165
+
166
+ const loaded = registry.getProfile(profileId);
167
+
168
+ expect(loaded).toBeDefined();
169
+ expect(loaded?.name).toBe("Registry Cache Regression");
170
+ expect(registry.listProfiles().length).toBe(initialCount + 1);
171
+ } finally {
172
+ process.env.HOME = originalHome;
173
+ fs.rmSync(tempHome, { recursive: true, force: true });
174
+ vi.resetModules();
175
+ }
176
+ });
177
+ });
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: sweep
3
+ description: Proactive project sweep agent that audits the codebase for improvement opportunities
4
+ ---
5
+
6
+ # Sweep Agent
7
+
8
+ You are a project sweep agent. Your role is to systematically audit the project and identify improvement opportunities.
9
+
10
+ ## What to Analyze
11
+
12
+ 1. **Code Quality**: Look for code smells, duplicated logic, overly complex functions, and opportunities for simplification
13
+ 2. **Test Coverage Gaps**: Identify important code paths that lack test coverage
14
+ 3. **Documentation Drift**: Find outdated comments, missing JSDoc, or docs that no longer match the code
15
+ 4. **Dependency Health**: Check for outdated dependencies, unused imports, or security advisories
16
+ 5. **Performance**: Spot potential performance issues like N+1 queries, unnecessary re-renders, or missing indexes
17
+ 6. **Consistency**: Find inconsistencies in naming conventions, patterns, or architectural approaches
18
+
19
+ ## Output Format
20
+
21
+ You MUST output a valid JSON array of improvement task proposals. Each entry should follow this schema:
22
+
23
+ ```json
24
+ [
25
+ {
26
+ "title": "Short descriptive title of the improvement",
27
+ "description": "Detailed description of what needs to be done and why",
28
+ "priority": 1,
29
+ "suggestedProfile": "general"
30
+ }
31
+ ]
32
+ ```
33
+
34
+ ### Fields
35
+
36
+ - `title`: Concise task title (imperative voice, e.g., "Refactor payment module to reduce duplication")
37
+ - `description`: 2-4 sentences explaining the issue, its impact, and the suggested approach
38
+ - `priority`: 1 (critical) to 4 (nice-to-have)
39
+ - `suggestedProfile`: Which agent profile should handle this task (e.g., "general", "code-reviewer", "document-writer")
40
+
41
+ ## Guidelines
42
+
43
+ - Focus on actionable, specific improvements — not vague suggestions
44
+ - Prioritize issues that affect reliability, security, or developer productivity
45
+ - Limit output to the 10 most impactful improvements
46
+ - Do not suggest improvements that are already tracked or in progress
47
+ - Each improvement should be independently completable as a single task
@@ -0,0 +1,12 @@
1
+ id: sweep
2
+ name: Sweep
3
+ version: "1.0.0"
4
+ domain: work
5
+ tags: [sweep, audit, improvement, maintenance]
6
+ supportedRuntimes: [claude-code]
7
+
8
+ temperature: 0.3
9
+ maxTurns: 50
10
+ outputFormat: json
11
+
12
+ author: stagent
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ DEFAULT_AGENT_RUNTIME,
4
+ getRuntimeCapabilities,
5
+ getRuntimeCatalogEntry,
6
+ listRuntimeCatalog,
7
+ resolveAgentRuntime,
8
+ } from "@/lib/agents/runtime/catalog";
9
+
10
+ describe("runtime catalog", () => {
11
+ it("defaults to the Claude runtime", () => {
12
+ expect(resolveAgentRuntime()).toBe(DEFAULT_AGENT_RUNTIME);
13
+ });
14
+
15
+ it("returns runtime metadata and capabilities", () => {
16
+ const runtime = getRuntimeCatalogEntry("claude-code");
17
+ const capabilities = getRuntimeCapabilities("claude-code");
18
+
19
+ expect(runtime.label).toBe("Claude Code");
20
+ expect(capabilities.resume).toBe(true);
21
+ expect(capabilities.profileTests).toBe(true);
22
+ });
23
+
24
+ it("lists the OpenAI Codex runtime", () => {
25
+ const runtimes = listRuntimeCatalog();
26
+
27
+ expect(runtimes.some((runtime) => runtime.id === "openai-codex-app-server")).toBe(
28
+ true
29
+ );
30
+ expect(getRuntimeCapabilities("openai-codex-app-server").resume).toBe(true);
31
+ });
32
+
33
+ it("throws for unknown runtime ids", () => {
34
+ expect(() => resolveAgentRuntime("unknown-runtime")).toThrow(
35
+ "Unknown agent type: unknown-runtime"
36
+ );
37
+ });
38
+ });
@@ -591,7 +591,7 @@ async function initializeOpenAIClient(
591
591
  await client.request("initialize", {
592
592
  clientInfo: {
593
593
  name: "Stagent",
594
- version: "0.1.0",
594
+ version: "0.1.1",
595
595
  },
596
596
  capabilities: null,
597
597
  });
@@ -0,0 +1,65 @@
1
+ import { db } from "@/lib/db";
2
+ import { tasks } from "@/lib/db/schema";
3
+ import { eq } from "drizzle-orm";
4
+
5
+ interface SweepProposal {
6
+ title: string;
7
+ description: string;
8
+ priority: number;
9
+ suggestedProfile?: string;
10
+ }
11
+
12
+ /**
13
+ * Process the result of a sweep task — parse the JSON output and create
14
+ * improvement tasks in the database.
15
+ */
16
+ export async function processSweepResult(taskId: string): Promise<void> {
17
+ const [task] = await db
18
+ .select({ result: tasks.result, projectId: tasks.projectId })
19
+ .from(tasks)
20
+ .where(eq(tasks.id, taskId));
21
+
22
+ if (!task?.result) return;
23
+
24
+ // Try to extract JSON array from the result
25
+ let proposals: SweepProposal[];
26
+ try {
27
+ // The result might have surrounding text — try to find JSON array
28
+ const jsonMatch = task.result.match(/\[[\s\S]*\]/);
29
+ if (!jsonMatch) return;
30
+ proposals = JSON.parse(jsonMatch[0]);
31
+ } catch {
32
+ console.error("[sweep] Failed to parse sweep result as JSON");
33
+ return;
34
+ }
35
+
36
+ if (!Array.isArray(proposals) || proposals.length === 0) return;
37
+
38
+ // Create improvement tasks
39
+ const now = new Date();
40
+ const values = proposals
41
+ .filter(
42
+ (p) =>
43
+ p && typeof p.title === "string" && typeof p.description === "string"
44
+ )
45
+ .slice(0, 10) // Cap at 10 tasks
46
+ .map((proposal) => ({
47
+ id: crypto.randomUUID(),
48
+ projectId: task.projectId,
49
+ title: proposal.title.slice(0, 200),
50
+ description: `[Sweep-generated] ${proposal.description}`,
51
+ status: "planned" as const,
52
+ priority: Math.min(Math.max(proposal.priority ?? 3, 1), 4),
53
+ agentProfile: proposal.suggestedProfile ?? "general",
54
+ resumeCount: 0,
55
+ createdAt: now,
56
+ updatedAt: now,
57
+ }));
58
+
59
+ if (values.length > 0) {
60
+ await db.insert(tasks).values(values);
61
+ console.log(
62
+ `[sweep] Created ${values.length} improvement tasks from sweep ${taskId}`
63
+ );
64
+ }
65
+ }
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ TASK_STATUSES,
4
+ COLUMN_ORDER,
5
+ VALID_TRANSITIONS,
6
+ USER_DRAG_TRANSITIONS,
7
+ isValidTransition,
8
+ isValidDragTransition,
9
+ } from "@/lib/constants/task-status";
10
+ import type { TaskStatus } from "@/lib/constants/task-status";
11
+
12
+ describe("TASK_STATUSES", () => {
13
+ it("contains all 6 statuses", () => {
14
+ expect(TASK_STATUSES).toHaveLength(6);
15
+ expect(TASK_STATUSES).toContain("planned");
16
+ expect(TASK_STATUSES).toContain("queued");
17
+ expect(TASK_STATUSES).toContain("running");
18
+ expect(TASK_STATUSES).toContain("completed");
19
+ expect(TASK_STATUSES).toContain("failed");
20
+ expect(TASK_STATUSES).toContain("cancelled");
21
+ });
22
+ });
23
+
24
+ describe("COLUMN_ORDER", () => {
25
+ it("excludes cancelled from visible columns", () => {
26
+ expect(COLUMN_ORDER).not.toContain("cancelled");
27
+ });
28
+
29
+ it("has 5 columns in display order", () => {
30
+ expect(COLUMN_ORDER).toEqual(["planned", "queued", "running", "completed", "failed"]);
31
+ });
32
+ });
33
+
34
+ describe("VALID_TRANSITIONS", () => {
35
+ it("defines transitions for every status", () => {
36
+ for (const status of TASK_STATUSES) {
37
+ expect(VALID_TRANSITIONS[status]).toBeDefined();
38
+ expect(Array.isArray(VALID_TRANSITIONS[status])).toBe(true);
39
+ }
40
+ });
41
+
42
+ it("allows planned → queued and planned → cancelled", () => {
43
+ expect(VALID_TRANSITIONS.planned).toContain("queued");
44
+ expect(VALID_TRANSITIONS.planned).toContain("cancelled");
45
+ });
46
+
47
+ it("does not allow running → queued (no backward skip)", () => {
48
+ expect(VALID_TRANSITIONS.running).not.toContain("queued");
49
+ });
50
+
51
+ it("allows failed → planned (retry path)", () => {
52
+ expect(VALID_TRANSITIONS.failed).toContain("planned");
53
+ });
54
+
55
+ it("allows failed → queued (direct re-queue)", () => {
56
+ expect(VALID_TRANSITIONS.failed).toContain("queued");
57
+ });
58
+
59
+ it("allows failed → running (resume path)", () => {
60
+ expect(VALID_TRANSITIONS.failed).toContain("running");
61
+ });
62
+
63
+ it("allows cancelled → running (resume path)", () => {
64
+ expect(VALID_TRANSITIONS.cancelled).toContain("running");
65
+ });
66
+ });
67
+
68
+ describe("isValidTransition", () => {
69
+ it("returns true for valid transitions", () => {
70
+ expect(isValidTransition("planned", "queued")).toBe(true);
71
+ expect(isValidTransition("queued", "running")).toBe(true);
72
+ expect(isValidTransition("running", "completed")).toBe(true);
73
+ expect(isValidTransition("running", "failed")).toBe(true);
74
+ });
75
+
76
+ it("returns true for resume transitions", () => {
77
+ expect(isValidTransition("failed", "running")).toBe(true);
78
+ expect(isValidTransition("cancelled", "running")).toBe(true);
79
+ });
80
+
81
+ it("returns false for invalid transitions", () => {
82
+ expect(isValidTransition("planned", "completed")).toBe(false);
83
+ expect(isValidTransition("completed", "running")).toBe(false);
84
+ expect(isValidTransition("running", "queued")).toBe(false);
85
+ });
86
+
87
+ it("returns false for same-status transitions", () => {
88
+ for (const status of TASK_STATUSES) {
89
+ expect(isValidTransition(status, status)).toBe(false);
90
+ }
91
+ });
92
+ });
93
+
94
+ describe("isValidDragTransition", () => {
95
+ it("allows planned ↔ queued drag", () => {
96
+ expect(isValidDragTransition("planned", "queued")).toBe(true);
97
+ expect(isValidDragTransition("queued", "planned")).toBe(true);
98
+ });
99
+
100
+ it("prevents dragging from running", () => {
101
+ expect(USER_DRAG_TRANSITIONS.running).toEqual([]);
102
+ for (const target of TASK_STATUSES) {
103
+ expect(isValidDragTransition("running", target)).toBe(false);
104
+ }
105
+ });
106
+
107
+ it("allows completed → planned (reset)", () => {
108
+ expect(isValidDragTransition("completed", "planned")).toBe(true);
109
+ });
110
+
111
+ it("allows failed → planned (retry via drag)", () => {
112
+ expect(isValidDragTransition("failed", "planned")).toBe(true);
113
+ });
114
+
115
+ it("prevents dragging to non-adjacent columns", () => {
116
+ expect(isValidDragTransition("planned", "running")).toBe(false);
117
+ expect(isValidDragTransition("planned", "completed")).toBe(false);
118
+ });
119
+ });
@@ -0,0 +1,141 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { AgentProfile } from "@/lib/agents/profiles/types";
3
+
4
+ const {
5
+ createProfile,
6
+ updateProfile,
7
+ deleteProfile,
8
+ getProfile,
9
+ isBuiltin,
10
+ } = vi.hoisted(() => ({
11
+ createProfile: vi.fn(),
12
+ updateProfile: vi.fn(),
13
+ deleteProfile: vi.fn(),
14
+ getProfile: vi.fn(),
15
+ isBuiltin: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("@/lib/agents/profiles/registry", () => ({
19
+ createProfile,
20
+ updateProfile,
21
+ deleteProfile,
22
+ getProfile,
23
+ isBuiltin,
24
+ }));
25
+
26
+ import {
27
+ SAMPLE_PROFILE_IDS,
28
+ clearSampleProfiles,
29
+ getSampleProfiles,
30
+ upsertSampleProfiles,
31
+ } from "../profiles";
32
+ import { createSchedules } from "../schedules";
33
+
34
+ describe("sample profile seeds", () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ getProfile.mockReturnValue(undefined);
38
+ isBuiltin.mockReturnValue(false);
39
+ });
40
+
41
+ it("defines stable reserved sample profile ids", () => {
42
+ expect(SAMPLE_PROFILE_IDS).toHaveLength(3);
43
+ expect(SAMPLE_PROFILE_IDS.every((id) => id.startsWith("stagent-sample-"))).toBe(true);
44
+ });
45
+
46
+ it("returns realistic sample profiles with skill markdown", () => {
47
+ const profiles = getSampleProfiles();
48
+
49
+ expect(profiles).toHaveLength(3);
50
+ expect(profiles.map((profile) => profile.config.id)).toEqual(
51
+ Array.from(SAMPLE_PROFILE_IDS)
52
+ );
53
+ expect(profiles[0].skillMd).toContain("description:");
54
+ expect(profiles[1].config.tags).toContain("messaging");
55
+ expect(profiles[2].config.domain).toBe("personal");
56
+ });
57
+
58
+ it("creates missing sample profiles", () => {
59
+ const count = upsertSampleProfiles();
60
+
61
+ expect(count).toBe(3);
62
+ expect(createProfile).toHaveBeenCalledTimes(3);
63
+ expect(updateProfile).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it("updates existing custom sample profiles", () => {
67
+ const existingProfile = { id: SAMPLE_PROFILE_IDS[0] } as AgentProfile;
68
+ getProfile.mockImplementation((id: string) =>
69
+ id === SAMPLE_PROFILE_IDS[0] ? existingProfile : undefined
70
+ );
71
+
72
+ const count = upsertSampleProfiles();
73
+
74
+ expect(count).toBe(3);
75
+ expect(updateProfile).toHaveBeenCalledTimes(1);
76
+ expect(updateProfile).toHaveBeenCalledWith(
77
+ SAMPLE_PROFILE_IDS[0],
78
+ expect.objectContaining({ id: SAMPLE_PROFILE_IDS[0] }),
79
+ expect.stringContaining("Revenue Operations Analyst")
80
+ );
81
+ expect(createProfile).toHaveBeenCalledTimes(2);
82
+ });
83
+
84
+ it("throws if a sample id collides with a built-in profile", () => {
85
+ getProfile.mockReturnValue({ id: SAMPLE_PROFILE_IDS[0] } as AgentProfile);
86
+ isBuiltin.mockImplementation((id: string) => id === SAMPLE_PROFILE_IDS[0]);
87
+
88
+ expect(() => upsertSampleProfiles()).toThrow(/collides with a built-in profile/);
89
+ expect(updateProfile).not.toHaveBeenCalled();
90
+ expect(createProfile).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it("deletes only existing non-builtin sample profiles", () => {
94
+ getProfile.mockImplementation((id: string) =>
95
+ id === SAMPLE_PROFILE_IDS[0] || id === SAMPLE_PROFILE_IDS[2]
96
+ ? ({ id } as AgentProfile)
97
+ : undefined
98
+ );
99
+ isBuiltin.mockImplementation((id: string) => id === SAMPLE_PROFILE_IDS[2]);
100
+
101
+ const deleted = clearSampleProfiles();
102
+
103
+ expect(deleted).toBe(1);
104
+ expect(deleteProfile).toHaveBeenCalledTimes(1);
105
+ expect(deleteProfile).toHaveBeenCalledWith(SAMPLE_PROFILE_IDS[0]);
106
+ });
107
+ });
108
+
109
+ describe("schedule seeds", () => {
110
+ it("creates schedules tied to seeded projects and profile surfaces", () => {
111
+ const projectIds = [
112
+ "project-investments",
113
+ "project-launch",
114
+ "project-pipeline",
115
+ "project-trip",
116
+ "project-tax",
117
+ ];
118
+
119
+ const schedules = createSchedules(projectIds);
120
+
121
+ expect(schedules).toHaveLength(4);
122
+ expect(schedules.map((schedule) => schedule.projectId)).toEqual([
123
+ projectIds[0],
124
+ projectIds[1],
125
+ projectIds[2],
126
+ projectIds[4],
127
+ ]);
128
+ expect(
129
+ schedules.filter((schedule) => schedule.status === "active").every(
130
+ (schedule) => schedule.nextFireAt instanceof Date
131
+ )
132
+ ).toBe(true);
133
+ expect(
134
+ schedules.filter((schedule) => schedule.status !== "active").every(
135
+ (schedule) => schedule.nextFireAt === null
136
+ )
137
+ ).toBe(true);
138
+ expect(schedules.some((schedule) => schedule.agentProfile === SAMPLE_PROFILE_IDS[0])).toBe(true);
139
+ expect(schedules.some((schedule) => schedule.agentProfile === "project-manager")).toBe(true);
140
+ });
141
+ });