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.
- package/README.md +33 -30
- package/dist/cli.js +376 -49
- package/package.json +23 -24
- package/public/desktop-icon-512.png +0 -0
- package/public/icon-512.png +0 -0
- package/src/app/api/data/clear/route.ts +0 -7
- package/src/app/api/data/seed/route.ts +0 -7
- package/src/app/api/profiles/[id]/context/route.ts +109 -0
- package/src/components/dashboard/__tests__/accessibility.test.tsx +42 -0
- package/src/components/documents/__tests__/document-upload-dialog.test.tsx +46 -0
- package/src/components/notifications/__tests__/pending-approval-host.test.tsx +122 -0
- package/src/components/notifications/__tests__/permission-response-actions.test.tsx +79 -0
- package/src/components/notifications/pending-approval-host.tsx +49 -25
- package/src/components/profiles/context-proposal-review.tsx +145 -0
- package/src/components/profiles/learned-context-panel.tsx +286 -0
- package/src/components/profiles/profile-detail-view.tsx +4 -0
- package/src/components/projects/__tests__/dialog-focus.test.tsx +87 -0
- package/src/components/tasks/__tests__/kanban-board-accessibility.test.tsx +59 -0
- package/src/lib/__tests__/setup-verify.test.ts +28 -0
- package/src/lib/__tests__/utils.test.ts +29 -0
- package/src/lib/agents/__tests__/claude-agent.test.ts +946 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +63 -0
- package/src/lib/agents/__tests__/router.test.ts +61 -0
- package/src/lib/agents/claude-agent.ts +34 -5
- package/src/lib/agents/learned-context.ts +322 -0
- package/src/lib/agents/pattern-extractor.ts +150 -0
- package/src/lib/agents/profiles/__tests__/compatibility.test.ts +76 -0
- package/src/lib/agents/profiles/__tests__/registry.test.ts +177 -0
- package/src/lib/agents/profiles/builtins/sweep/SKILL.md +47 -0
- package/src/lib/agents/profiles/builtins/sweep/profile.yaml +12 -0
- package/src/lib/agents/runtime/__tests__/catalog.test.ts +38 -0
- package/src/lib/agents/runtime/openai-codex.ts +1 -1
- package/src/lib/agents/sweep.ts +65 -0
- package/src/lib/constants/__tests__/task-status.test.ts +119 -0
- package/src/lib/data/seed-data/__tests__/profiles.test.ts +141 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +56 -0
- package/src/lib/db/bootstrap.ts +301 -0
- package/src/lib/db/index.ts +2 -205
- package/src/lib/db/migrations/0004_add_documents.sql +2 -1
- package/src/lib/db/migrations/0005_add_document_preprocessing.sql +2 -0
- package/src/lib/db/migrations/0006_add_agent_profile.sql +1 -0
- package/src/lib/db/migrations/0007_add_usage_metering_ledger.sql +9 -2
- package/src/lib/db/migrations/meta/_journal.json +43 -1
- package/src/lib/db/schema.ts +34 -0
- package/src/lib/desktop/__tests__/sidecar-launch.test.ts +70 -0
- package/src/lib/desktop/sidecar-launch.ts +85 -0
- package/src/lib/documents/__tests__/context-builder.test.ts +57 -0
- package/src/lib/documents/__tests__/output-scanner.test.ts +141 -0
- package/src/lib/notifications/actionable.ts +21 -7
- package/src/lib/settings/__tests__/auth.test.ts +220 -0
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +181 -0
- package/src/lib/tauri-bridge.ts +138 -0
- package/src/lib/usage/__tests__/ledger.test.ts +284 -0
- package/src/lib/utils/__tests__/crypto.test.ts +90 -0
- package/src/lib/validators/__tests__/profile.test.ts +119 -0
- package/src/lib/validators/__tests__/project.test.ts +82 -0
- package/src/lib/validators/__tests__/settings.test.ts +151 -0
- package/src/lib/validators/__tests__/task.test.ts +144 -0
- package/src/lib/workflows/__tests__/definition-validation.test.ts +164 -0
- package/src/lib/workflows/__tests__/engine.test.ts +114 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/parallel.test.ts +75 -0
- package/src/lib/workflows/__tests__/swarm.test.ts +97 -0
- 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,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
|
+
});
|
|
@@ -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
|
+
});
|