stagent 0.1.10 → 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.
- package/README.md +58 -27
- package/package.json +3 -3
- package/src/__tests__/e2e/blueprint.test.ts +63 -0
- package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
- package/src/__tests__/e2e/helpers.ts +286 -0
- package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
- package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
- package/src/__tests__/e2e/setup.ts +156 -0
- package/src/__tests__/e2e/single-task.test.ts +170 -0
- package/src/app/api/command-palette/recent/route.ts +41 -18
- package/src/app/api/context/batch/route.ts +44 -0
- package/src/app/api/permissions/presets/route.ts +80 -0
- package/src/app/api/playbook/status/route.ts +15 -0
- package/src/app/api/profiles/route.ts +23 -21
- package/src/app/api/settings/pricing/route.ts +15 -0
- package/src/app/costs/page.tsx +53 -43
- package/src/app/globals.css +0 -5
- package/src/app/playbook/[slug]/page.tsx +76 -0
- package/src/app/playbook/page.tsx +54 -0
- package/src/app/profiles/page.tsx +7 -4
- package/src/app/settings/page.tsx +2 -2
- package/src/app/tasks/page.tsx +5 -0
- package/src/components/costs/cost-dashboard.tsx +226 -320
- package/src/components/dashboard/activity-feed.tsx +6 -2
- package/src/components/notifications/batch-proposal-review.tsx +150 -0
- package/src/components/notifications/notification-item.tsx +6 -3
- package/src/components/notifications/pending-approval-host.tsx +57 -11
- package/src/components/playbook/adoption-heatmap.tsx +69 -0
- package/src/components/playbook/journey-card.tsx +110 -0
- package/src/components/playbook/playbook-action-button.tsx +22 -0
- package/src/components/playbook/playbook-browser.tsx +143 -0
- package/src/components/playbook/playbook-card.tsx +102 -0
- package/src/components/playbook/playbook-detail-view.tsx +223 -0
- package/src/components/playbook/playbook-homepage.tsx +142 -0
- package/src/components/playbook/playbook-toc.tsx +90 -0
- package/src/components/playbook/playbook-updated-badge.tsx +23 -0
- package/src/components/playbook/related-docs.tsx +30 -0
- package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
- package/src/components/profiles/context-proposal-review.tsx +7 -3
- package/src/components/profiles/learned-context-panel.tsx +116 -8
- package/src/components/profiles/profile-detail-view.tsx +7 -19
- package/src/components/profiles/profile-form-view.tsx +0 -22
- package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
- package/src/components/settings/api-key-form.tsx +5 -43
- package/src/components/settings/auth-config-section.tsx +10 -6
- package/src/components/settings/auth-status-badge.tsx +8 -0
- package/src/components/settings/budget-guardrails-section.tsx +403 -620
- package/src/components/settings/connection-test-control.tsx +63 -0
- package/src/components/settings/permissions-section.tsx +85 -75
- package/src/components/settings/permissions-sections.tsx +24 -0
- package/src/components/settings/presets-section.tsx +159 -0
- package/src/components/settings/pricing-registry-panel.tsx +164 -0
- package/src/components/shared/app-sidebar.tsx +2 -0
- package/src/components/shared/command-palette.tsx +30 -0
- package/src/components/shared/light-markdown.tsx +134 -0
- package/src/components/workflows/loop-status-view.tsx +8 -4
- package/src/components/workflows/workflow-status-view.tsx +16 -9
- package/src/lib/agents/__tests__/claude-agent.test.ts +7 -2
- package/src/lib/agents/__tests__/learned-context.test.ts +500 -0
- package/src/lib/agents/__tests__/pattern-extractor.test.ts +243 -0
- package/src/lib/agents/__tests__/sweep.test.ts +202 -0
- package/src/lib/agents/claude-agent.ts +104 -78
- package/src/lib/agents/learned-context.ts +32 -28
- package/src/lib/agents/learning-session.ts +234 -0
- package/src/lib/agents/pattern-extractor.ts +34 -64
- package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
- package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/general/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/researcher/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/sweep/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +0 -1
- package/src/lib/agents/profiles/registry.ts +0 -1
- package/src/lib/agents/profiles/sort.ts +7 -0
- package/src/lib/agents/profiles/types.ts +0 -1
- package/src/lib/agents/runtime/catalog.ts +1 -1
- package/src/lib/agents/runtime/claude.ts +66 -0
- package/src/lib/constants/settings.ts +1 -0
- package/src/lib/constants/task-status.ts +6 -0
- package/src/lib/data/seed-data/profiles.ts +0 -3
- package/src/lib/db/schema.ts +3 -0
- package/src/lib/docs/adoption.ts +105 -0
- package/src/lib/docs/journey-tracker.ts +21 -0
- package/src/lib/docs/reader.ts +102 -0
- package/src/lib/docs/types.ts +54 -0
- package/src/lib/docs/usage-stage.ts +60 -0
- package/src/lib/notifications/actionable.ts +18 -10
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
- package/src/lib/settings/budget-guardrails.ts +213 -85
- package/src/lib/settings/permission-presets.ts +150 -0
- package/src/lib/settings/runtime-setup.ts +71 -0
- package/src/lib/usage/__tests__/ledger.test.ts +29 -5
- package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
- package/src/lib/usage/ledger.ts +4 -2
- package/src/lib/usage/pricing-registry.ts +570 -0
- package/src/lib/usage/pricing.ts +15 -41
- package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
- package/src/lib/utils/learned-context-history.ts +150 -0
- package/src/lib/validators/__tests__/profile.test.ts +0 -15
- package/src/lib/validators/__tests__/settings.test.ts +23 -16
- package/src/lib/validators/profile.ts +0 -1
- package/src/lib/validators/settings.ts +3 -9
- package/src/lib/workflows/__tests__/engine.test.ts +2 -0
- package/src/lib/workflows/engine.ts +20 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Mock infrastructure ──────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
mockAll,
|
|
7
|
+
mockLimit,
|
|
8
|
+
mockOrderBy,
|
|
9
|
+
mockWhere,
|
|
10
|
+
mockFrom,
|
|
11
|
+
mockSelect,
|
|
12
|
+
mockValues,
|
|
13
|
+
mockInsert,
|
|
14
|
+
} = vi.hoisted(() => {
|
|
15
|
+
const mockAll = vi.fn();
|
|
16
|
+
const mockLimit = vi.fn().mockReturnValue(mockAll);
|
|
17
|
+
const mockOrderBy = vi.fn().mockReturnValue({ limit: mockLimit });
|
|
18
|
+
const mockWhere = vi.fn().mockReturnValue({ orderBy: mockOrderBy });
|
|
19
|
+
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere });
|
|
20
|
+
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom });
|
|
21
|
+
const mockValues = vi.fn().mockResolvedValue(undefined);
|
|
22
|
+
const mockInsert = vi.fn().mockReturnValue({ values: mockValues });
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
mockAll,
|
|
26
|
+
mockLimit,
|
|
27
|
+
mockOrderBy,
|
|
28
|
+
mockWhere,
|
|
29
|
+
mockFrom,
|
|
30
|
+
mockSelect,
|
|
31
|
+
mockValues,
|
|
32
|
+
mockInsert,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
vi.mock("@/lib/db", () => ({
|
|
37
|
+
db: {
|
|
38
|
+
select: mockSelect,
|
|
39
|
+
insert: mockInsert,
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("@/lib/db/schema", () => ({
|
|
44
|
+
tasks: {
|
|
45
|
+
id: "id",
|
|
46
|
+
title: "title",
|
|
47
|
+
description: "description",
|
|
48
|
+
result: "result",
|
|
49
|
+
},
|
|
50
|
+
agentLogs: {
|
|
51
|
+
taskId: "task_id",
|
|
52
|
+
event: "event",
|
|
53
|
+
payload: "payload",
|
|
54
|
+
timestamp: "timestamp",
|
|
55
|
+
},
|
|
56
|
+
notifications: { id: "id" },
|
|
57
|
+
learnedContext: {
|
|
58
|
+
profileId: "profile_id",
|
|
59
|
+
version: "version",
|
|
60
|
+
changeType: "change_type",
|
|
61
|
+
content: "content",
|
|
62
|
+
proposalNotificationId: "proposal_notification_id",
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
vi.mock("drizzle-orm", () => ({
|
|
67
|
+
eq: vi.fn((_col: string, val: unknown) => ({ col: _col, val })),
|
|
68
|
+
and: vi.fn((...conditions: unknown[]) => conditions),
|
|
69
|
+
desc: vi.fn((col: string) => ({ desc: col })),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
const {
|
|
73
|
+
mockRunMetaCompletion,
|
|
74
|
+
mockGetActiveLearnedContext,
|
|
75
|
+
mockProposeContextAddition,
|
|
76
|
+
} = vi.hoisted(() => ({
|
|
77
|
+
mockRunMetaCompletion: vi.fn(),
|
|
78
|
+
mockGetActiveLearnedContext: vi.fn().mockReturnValue(null),
|
|
79
|
+
mockProposeContextAddition: vi.fn().mockResolvedValue("notif-123"),
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
vi.mock("../runtime/claude", () => ({
|
|
83
|
+
runMetaCompletion: mockRunMetaCompletion,
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
vi.mock("../learned-context", () => ({
|
|
87
|
+
getActiveLearnedContext: mockGetActiveLearnedContext,
|
|
88
|
+
proposeContextAddition: mockProposeContextAddition,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
import { analyzeForLearnedPatterns } from "../pattern-extractor";
|
|
92
|
+
|
|
93
|
+
// ─── Setup ────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
vi.resetAllMocks();
|
|
97
|
+
mockSelect.mockReturnValue({ from: mockFrom });
|
|
98
|
+
mockFrom.mockReturnValue({ where: mockWhere });
|
|
99
|
+
mockWhere.mockReturnValue({ orderBy: mockOrderBy });
|
|
100
|
+
mockOrderBy.mockReturnValue({ limit: mockLimit });
|
|
101
|
+
mockLimit.mockReturnValue(mockAll);
|
|
102
|
+
mockInsert.mockReturnValue({ values: mockValues });
|
|
103
|
+
mockValues.mockResolvedValue(undefined);
|
|
104
|
+
mockGetActiveLearnedContext.mockReturnValue(null);
|
|
105
|
+
mockProposeContextAddition.mockResolvedValue("notif-123");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ═════════════════════════════════════════════════════════════════════
|
|
109
|
+
|
|
110
|
+
describe("analyzeForLearnedPatterns", () => {
|
|
111
|
+
it("returns null when task not found", async () => {
|
|
112
|
+
// Task query returns empty
|
|
113
|
+
mockWhere.mockResolvedValueOnce([]);
|
|
114
|
+
|
|
115
|
+
const result = await analyzeForLearnedPatterns("task-1", "general");
|
|
116
|
+
|
|
117
|
+
expect(result).toBeNull();
|
|
118
|
+
expect(mockRunMetaCompletion).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("proposes context when patterns are found", async () => {
|
|
122
|
+
// Task query
|
|
123
|
+
mockWhere.mockResolvedValueOnce([
|
|
124
|
+
{
|
|
125
|
+
title: "Fix auth bug",
|
|
126
|
+
description: "Token validation was broken",
|
|
127
|
+
result: "Fixed by adding null check",
|
|
128
|
+
},
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
// Agent logs query (returns array directly from .limit())
|
|
132
|
+
mockLimit.mockResolvedValueOnce([
|
|
133
|
+
{ event: "completed", payload: '{"result":"done"}' },
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
// runMetaCompletion returns JSON text with patterns
|
|
137
|
+
mockRunMetaCompletion.mockResolvedValue({
|
|
138
|
+
text: JSON.stringify([
|
|
139
|
+
{
|
|
140
|
+
title: "Null check tokens",
|
|
141
|
+
description: "Always validate token existence before parsing",
|
|
142
|
+
category: "error_resolution",
|
|
143
|
+
},
|
|
144
|
+
]),
|
|
145
|
+
usage: {},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const result = await analyzeForLearnedPatterns("task-1", "general");
|
|
149
|
+
|
|
150
|
+
expect(result).toBe("notif-123");
|
|
151
|
+
expect(mockRunMetaCompletion).toHaveBeenCalledWith(
|
|
152
|
+
expect.objectContaining({ activityType: "pattern_extraction" })
|
|
153
|
+
);
|
|
154
|
+
expect(mockProposeContextAddition).toHaveBeenCalledWith(
|
|
155
|
+
"general",
|
|
156
|
+
"task-1",
|
|
157
|
+
expect.stringContaining("Null check tokens")
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns null when no patterns are found", async () => {
|
|
162
|
+
mockWhere.mockResolvedValueOnce([
|
|
163
|
+
{ title: "Routine task", description: "Nothing special", result: "Done" },
|
|
164
|
+
]);
|
|
165
|
+
mockLimit.mockResolvedValueOnce([]);
|
|
166
|
+
|
|
167
|
+
mockRunMetaCompletion.mockResolvedValue({
|
|
168
|
+
text: "[]",
|
|
169
|
+
usage: {},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const result = await analyzeForLearnedPatterns("task-1", "general");
|
|
173
|
+
|
|
174
|
+
expect(result).toBeNull();
|
|
175
|
+
expect(mockProposeContextAddition).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("returns null when response has no valid JSON array", async () => {
|
|
179
|
+
mockWhere.mockResolvedValueOnce([
|
|
180
|
+
{ title: "Task", description: "Desc", result: "Done" },
|
|
181
|
+
]);
|
|
182
|
+
mockLimit.mockResolvedValueOnce([]);
|
|
183
|
+
|
|
184
|
+
mockRunMetaCompletion.mockResolvedValue({
|
|
185
|
+
text: "No patterns found in this task.",
|
|
186
|
+
usage: {},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const result = await analyzeForLearnedPatterns("task-1", "general");
|
|
190
|
+
|
|
191
|
+
expect(result).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("passes existing learned context for dedup", async () => {
|
|
195
|
+
mockWhere.mockResolvedValueOnce([
|
|
196
|
+
{ title: "Task", description: "Desc", result: "Done" },
|
|
197
|
+
]);
|
|
198
|
+
mockLimit.mockResolvedValueOnce([]);
|
|
199
|
+
mockGetActiveLearnedContext.mockReturnValue("Existing pattern: check nulls");
|
|
200
|
+
|
|
201
|
+
mockRunMetaCompletion.mockResolvedValue({
|
|
202
|
+
text: "[]",
|
|
203
|
+
usage: {},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await analyzeForLearnedPatterns("task-1", "general");
|
|
207
|
+
|
|
208
|
+
// Verify prompt includes existing context for dedup
|
|
209
|
+
const callArgs = mockRunMetaCompletion.mock.calls[0][0];
|
|
210
|
+
expect(callArgs.prompt).toContain("Existing pattern: check nulls");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("formats multiple patterns correctly", async () => {
|
|
214
|
+
mockWhere.mockResolvedValueOnce([
|
|
215
|
+
{ title: "Task", description: "Desc", result: "Done" },
|
|
216
|
+
]);
|
|
217
|
+
mockLimit.mockResolvedValueOnce([]);
|
|
218
|
+
|
|
219
|
+
mockRunMetaCompletion.mockResolvedValue({
|
|
220
|
+
text: JSON.stringify([
|
|
221
|
+
{
|
|
222
|
+
title: "Pattern A",
|
|
223
|
+
description: "Description A",
|
|
224
|
+
category: "best_practice",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
title: "Pattern B",
|
|
228
|
+
description: "Description B",
|
|
229
|
+
category: "shortcut",
|
|
230
|
+
},
|
|
231
|
+
]),
|
|
232
|
+
usage: {},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await analyzeForLearnedPatterns("task-1", "code-reviewer");
|
|
236
|
+
|
|
237
|
+
const additions = mockProposeContextAddition.mock.calls[0][2];
|
|
238
|
+
expect(additions).toContain("### Pattern A [best_practice]");
|
|
239
|
+
expect(additions).toContain("### Pattern B [shortcut]");
|
|
240
|
+
expect(additions).toContain("Description A");
|
|
241
|
+
expect(additions).toContain("Description B");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ─── Mock infrastructure ──────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const { mockWhere, mockFrom, mockSelect, mockValues, mockInsert } =
|
|
6
|
+
vi.hoisted(() => {
|
|
7
|
+
const mockWhere = vi.fn();
|
|
8
|
+
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere });
|
|
9
|
+
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom });
|
|
10
|
+
const mockValues = vi.fn().mockResolvedValue(undefined);
|
|
11
|
+
const mockInsert = vi.fn().mockReturnValue({ values: mockValues });
|
|
12
|
+
|
|
13
|
+
return { mockWhere, mockFrom, mockSelect, mockValues, mockInsert };
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
vi.mock("@/lib/db", () => ({
|
|
17
|
+
db: {
|
|
18
|
+
select: mockSelect,
|
|
19
|
+
insert: mockInsert,
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("@/lib/db/schema", () => ({
|
|
24
|
+
tasks: {
|
|
25
|
+
id: "id",
|
|
26
|
+
result: "result",
|
|
27
|
+
projectId: "project_id",
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("drizzle-orm", () => ({
|
|
32
|
+
eq: vi.fn((_col: string, val: unknown) => ({ col: _col, val })),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import { processSweepResult } from "../sweep";
|
|
36
|
+
|
|
37
|
+
// ─── Setup ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.resetAllMocks();
|
|
41
|
+
mockSelect.mockReturnValue({ from: mockFrom });
|
|
42
|
+
mockFrom.mockReturnValue({ where: mockWhere });
|
|
43
|
+
mockInsert.mockReturnValue({ values: mockValues });
|
|
44
|
+
mockValues.mockResolvedValue(undefined);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ═════════════════════════════════════════════════════════════════════
|
|
48
|
+
// processSweepResult
|
|
49
|
+
// ═════════════════════════════════════════════════════════════════════
|
|
50
|
+
|
|
51
|
+
describe("processSweepResult", () => {
|
|
52
|
+
it("creates improvement tasks from valid JSON array in result", async () => {
|
|
53
|
+
const proposals = [
|
|
54
|
+
{
|
|
55
|
+
title: "Refactor auth module",
|
|
56
|
+
description: "Reduce duplication in token validation",
|
|
57
|
+
priority: 2,
|
|
58
|
+
suggestedProfile: "code-reviewer",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
title: "Add missing test for parser",
|
|
62
|
+
description: "The CSV parser has no test coverage",
|
|
63
|
+
priority: 3,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
mockWhere.mockResolvedValueOnce([
|
|
68
|
+
{ result: JSON.stringify(proposals), projectId: "proj-1" },
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
await processSweepResult("sweep-task-1");
|
|
72
|
+
|
|
73
|
+
expect(mockInsert).toHaveBeenCalledOnce();
|
|
74
|
+
expect(mockValues).toHaveBeenCalledWith(
|
|
75
|
+
expect.arrayContaining([
|
|
76
|
+
expect.objectContaining({
|
|
77
|
+
title: "Refactor auth module",
|
|
78
|
+
description: "[Sweep-generated] Reduce duplication in token validation",
|
|
79
|
+
status: "planned",
|
|
80
|
+
priority: 2,
|
|
81
|
+
agentProfile: "code-reviewer",
|
|
82
|
+
projectId: "proj-1",
|
|
83
|
+
}),
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
title: "Add missing test for parser",
|
|
86
|
+
agentProfile: "general", // default when not specified
|
|
87
|
+
priority: 3,
|
|
88
|
+
}),
|
|
89
|
+
])
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("extracts JSON array from surrounding text", async () => {
|
|
94
|
+
const result = `Here are my findings:\n${JSON.stringify([
|
|
95
|
+
{ title: "Fix bug", description: "Important fix", priority: 1 },
|
|
96
|
+
])}\nEnd of report.`;
|
|
97
|
+
|
|
98
|
+
mockWhere.mockResolvedValueOnce([{ result, projectId: null }]);
|
|
99
|
+
|
|
100
|
+
await processSweepResult("sweep-task-1");
|
|
101
|
+
|
|
102
|
+
expect(mockInsert).toHaveBeenCalledOnce();
|
|
103
|
+
expect(mockValues).toHaveBeenCalledWith(
|
|
104
|
+
expect.arrayContaining([
|
|
105
|
+
expect.objectContaining({ title: "Fix bug" }),
|
|
106
|
+
])
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("silently returns when task has no result", async () => {
|
|
111
|
+
mockWhere.mockResolvedValueOnce([{ result: null, projectId: null }]);
|
|
112
|
+
|
|
113
|
+
await processSweepResult("sweep-task-1");
|
|
114
|
+
|
|
115
|
+
expect(mockInsert).not.toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("silently returns when task not found", async () => {
|
|
119
|
+
mockWhere.mockResolvedValueOnce([]);
|
|
120
|
+
|
|
121
|
+
await processSweepResult("sweep-task-1");
|
|
122
|
+
|
|
123
|
+
expect(mockInsert).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("silently returns when result contains no JSON array", async () => {
|
|
127
|
+
mockWhere.mockResolvedValueOnce([
|
|
128
|
+
{ result: "No actionable findings.", projectId: null },
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
await processSweepResult("sweep-task-1");
|
|
132
|
+
|
|
133
|
+
expect(mockInsert).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("silently returns when result contains invalid JSON", async () => {
|
|
137
|
+
mockWhere.mockResolvedValueOnce([
|
|
138
|
+
{ result: "[{broken json}]", projectId: null },
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
await processSweepResult("sweep-task-1");
|
|
142
|
+
|
|
143
|
+
expect(mockInsert).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("caps tasks at 10", async () => {
|
|
147
|
+
const proposals = Array.from({ length: 15 }, (_, i) => ({
|
|
148
|
+
title: `Task ${i + 1}`,
|
|
149
|
+
description: `Description ${i + 1}`,
|
|
150
|
+
priority: 3,
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
mockWhere.mockResolvedValueOnce([
|
|
154
|
+
{ result: JSON.stringify(proposals), projectId: null },
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
await processSweepResult("sweep-task-1");
|
|
158
|
+
|
|
159
|
+
const insertedValues = mockValues.mock.calls[0][0] as unknown[];
|
|
160
|
+
expect(insertedValues).toHaveLength(10);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("clamps priority to 1-4 range", async () => {
|
|
164
|
+
mockWhere.mockResolvedValueOnce([
|
|
165
|
+
{
|
|
166
|
+
result: JSON.stringify([
|
|
167
|
+
{ title: "Low", description: "d", priority: 0 },
|
|
168
|
+
{ title: "High", description: "d", priority: 10 },
|
|
169
|
+
]),
|
|
170
|
+
projectId: null,
|
|
171
|
+
},
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
await processSweepResult("sweep-task-1");
|
|
175
|
+
|
|
176
|
+
expect(mockValues).toHaveBeenCalledWith(
|
|
177
|
+
expect.arrayContaining([
|
|
178
|
+
expect.objectContaining({ title: "Low", priority: 1 }),
|
|
179
|
+
expect.objectContaining({ title: "High", priority: 4 }),
|
|
180
|
+
])
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("filters out entries with missing title or description", async () => {
|
|
185
|
+
mockWhere.mockResolvedValueOnce([
|
|
186
|
+
{
|
|
187
|
+
result: JSON.stringify([
|
|
188
|
+
{ title: "Valid", description: "Has both" },
|
|
189
|
+
{ title: "No desc" },
|
|
190
|
+
{ description: "No title" },
|
|
191
|
+
{ title: "Also valid", description: "Also has both" },
|
|
192
|
+
]),
|
|
193
|
+
projectId: null,
|
|
194
|
+
},
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
await processSweepResult("sweep-task-1");
|
|
198
|
+
|
|
199
|
+
const insertedValues = mockValues.mock.calls[0][0] as unknown[];
|
|
200
|
+
expect(insertedValues).toHaveLength(2);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -4,7 +4,7 @@ import { db } from "@/lib/db";
|
|
|
4
4
|
import { tasks, projects, agentLogs, notifications } from "@/lib/db/schema";
|
|
5
5
|
import { eq } from "drizzle-orm";
|
|
6
6
|
import { setExecution, removeExecution } from "./execution-manager";
|
|
7
|
-
import { MAX_RESUME_COUNT } from "@/lib/constants/task-status";
|
|
7
|
+
import { MAX_RESUME_COUNT, DEFAULT_MAX_TURNS, DEFAULT_MAX_BUDGET_USD } from "@/lib/constants/task-status";
|
|
8
8
|
import { getAuthEnv, updateAuthStatus } from "@/lib/settings/auth";
|
|
9
9
|
import { buildDocumentContext } from "@/lib/documents/context-builder";
|
|
10
10
|
import {
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
scanTaskOutputDocuments,
|
|
14
14
|
} from "@/lib/documents/output-scanner";
|
|
15
15
|
import { getProfile } from "./profiles/registry";
|
|
16
|
-
import { resolveProfileRuntimePayload } from "./profiles/compatibility";
|
|
16
|
+
import { resolveProfileRuntimePayload, type ResolvedProfileRuntimePayload } from "./profiles/compatibility";
|
|
17
17
|
import type { CanUseToolPolicy } from "./profiles/types";
|
|
18
18
|
import { buildClaudeSdkEnv } from "./runtime/claude-sdk";
|
|
19
19
|
import { getActiveLearnedContext } from "./learned-context";
|
|
@@ -368,6 +368,76 @@ async function processAgentStream(
|
|
|
368
368
|
}
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Shared prompt & query context builder (F12: eliminate duplication)
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
interface TaskQueryContext {
|
|
376
|
+
/** User task content — goes into `prompt` */
|
|
377
|
+
userPrompt: string;
|
|
378
|
+
/** System instructions — goes into `options.systemPrompt` */
|
|
379
|
+
systemInstructions: string;
|
|
380
|
+
/** Resolved working directory */
|
|
381
|
+
cwd: string;
|
|
382
|
+
/** Profile payload (tools, MCP, policy) */
|
|
383
|
+
payload: ResolvedProfileRuntimePayload | null;
|
|
384
|
+
/** Profile's maxTurns or default */
|
|
385
|
+
maxTurns: number;
|
|
386
|
+
/** Profile's canUseToolPolicy */
|
|
387
|
+
canUseToolPolicy?: CanUseToolPolicy;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function buildTaskQueryContext(
|
|
391
|
+
task: { id: string; title: string; description?: string | null; projectId?: string | null },
|
|
392
|
+
profileId: string
|
|
393
|
+
): Promise<TaskQueryContext> {
|
|
394
|
+
const profile = getProfile(profileId);
|
|
395
|
+
const payload = profile
|
|
396
|
+
? resolveProfileRuntimePayload(profile, "claude-code")
|
|
397
|
+
: null;
|
|
398
|
+
if (payload && !payload.supported) {
|
|
399
|
+
throw new Error(payload.reason ?? `Profile "${profile?.name}" is not supported on Claude Code`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const profileInstructions = payload?.instructions ?? "";
|
|
403
|
+
const basePrompt = task.description || task.title;
|
|
404
|
+
const docContext = await buildDocumentContext(task.id);
|
|
405
|
+
const outputInstructions = buildTaskOutputInstructions(task.id);
|
|
406
|
+
const learnedCtx = getActiveLearnedContext(profileId);
|
|
407
|
+
const learnedCtxBlock = learnedCtx
|
|
408
|
+
? `## Learned Context\nPatterns and insights learned from previous tasks:\n\n${learnedCtx}`
|
|
409
|
+
: "";
|
|
410
|
+
|
|
411
|
+
// F1: Separate system instructions from user content
|
|
412
|
+
const systemInstructions = [profileInstructions, learnedCtxBlock, docContext, outputInstructions]
|
|
413
|
+
.filter(Boolean)
|
|
414
|
+
.join("\n\n");
|
|
415
|
+
|
|
416
|
+
// Resolve working directory: project's workingDirectory > process.cwd()
|
|
417
|
+
let cwd = process.cwd();
|
|
418
|
+
if (task.projectId) {
|
|
419
|
+
const [project] = await db
|
|
420
|
+
.select({ workingDirectory: projects.workingDirectory })
|
|
421
|
+
.from(projects)
|
|
422
|
+
.where(eq(projects.id, task.projectId));
|
|
423
|
+
if (project?.workingDirectory) {
|
|
424
|
+
cwd = project.workingDirectory;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// F9: Use profile maxTurns or fall back to default
|
|
429
|
+
const maxTurns = profile?.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
userPrompt: basePrompt,
|
|
433
|
+
systemInstructions,
|
|
434
|
+
cwd,
|
|
435
|
+
payload,
|
|
436
|
+
maxTurns,
|
|
437
|
+
canUseToolPolicy: payload?.canUseToolPolicy,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
371
441
|
export async function executeClaudeTask(taskId: string): Promise<void> {
|
|
372
442
|
const [task] = await db.select().from(tasks).where(eq(tasks.id, taskId));
|
|
373
443
|
if (!task) throw new Error(`Task ${taskId} not found`);
|
|
@@ -385,57 +455,35 @@ export async function executeClaudeTask(taskId: string): Promise<void> {
|
|
|
385
455
|
|
|
386
456
|
try {
|
|
387
457
|
await prepareTaskOutputDirectory(taskId, { clearExisting: true });
|
|
388
|
-
const
|
|
389
|
-
const payload = profile
|
|
390
|
-
? resolveProfileRuntimePayload(profile, "claude-code")
|
|
391
|
-
: null;
|
|
392
|
-
if (payload && !payload.supported) {
|
|
393
|
-
throw new Error(payload.reason ?? `Profile "${profile?.name}" is not supported on Claude Code`);
|
|
394
|
-
}
|
|
395
|
-
const systemPrompt = payload?.instructions ?? "";
|
|
396
|
-
const basePrompt = task.description || task.title;
|
|
397
|
-
const docContext = await buildDocumentContext(taskId);
|
|
398
|
-
const outputInstructions = buildTaskOutputInstructions(taskId);
|
|
399
|
-
const learnedCtx = getActiveLearnedContext(agentProfileId);
|
|
400
|
-
const learnedCtxBlock = learnedCtx
|
|
401
|
-
? `## Learned Context\nPatterns and insights learned from previous tasks:\n\n${learnedCtx}`
|
|
402
|
-
: "";
|
|
403
|
-
const prompt = [systemPrompt, learnedCtxBlock, docContext, outputInstructions, basePrompt]
|
|
404
|
-
.filter(Boolean)
|
|
405
|
-
.join("\n\n");
|
|
406
|
-
|
|
407
|
-
// Resolve working directory: project's workingDirectory > process.cwd()
|
|
408
|
-
let cwd = process.cwd();
|
|
409
|
-
if (task.projectId) {
|
|
410
|
-
const [project] = await db
|
|
411
|
-
.select({ workingDirectory: projects.workingDirectory })
|
|
412
|
-
.from(projects)
|
|
413
|
-
.where(eq(projects.id, task.projectId));
|
|
414
|
-
if (project?.workingDirectory) {
|
|
415
|
-
cwd = project.workingDirectory;
|
|
416
|
-
}
|
|
417
|
-
}
|
|
458
|
+
const ctx = await buildTaskQueryContext(task, agentProfileId);
|
|
418
459
|
|
|
419
|
-
const policyForTask = payload?.canUseToolPolicy;
|
|
420
460
|
const authEnv = await getAuthEnv();
|
|
421
461
|
const response = query({
|
|
422
|
-
prompt,
|
|
462
|
+
prompt: ctx.userPrompt,
|
|
423
463
|
options: {
|
|
424
464
|
abortController,
|
|
425
465
|
includePartialMessages: true,
|
|
426
|
-
cwd,
|
|
466
|
+
cwd: ctx.cwd,
|
|
427
467
|
env: buildClaudeSdkEnv(authEnv),
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
468
|
+
// F1: Use dedicated systemPrompt option with claude_code preset
|
|
469
|
+
systemPrompt: ctx.systemInstructions
|
|
470
|
+
? { type: "preset" as const, preset: "claude_code" as const, append: ctx.systemInstructions }
|
|
471
|
+
: { type: "preset" as const, preset: "claude_code" as const },
|
|
472
|
+
// F9: Bounded turn limit from profile or default
|
|
473
|
+
maxTurns: ctx.maxTurns,
|
|
474
|
+
// F4: Per-execution budget cap
|
|
475
|
+
maxBudgetUsd: DEFAULT_MAX_BUDGET_USD,
|
|
476
|
+
...(ctx.payload?.allowedTools && { allowedTools: ctx.payload.allowedTools }),
|
|
477
|
+
...(ctx.payload?.mcpServers &&
|
|
478
|
+
Object.keys(ctx.payload.mcpServers).length > 0 && {
|
|
479
|
+
mcpServers: ctx.payload.mcpServers,
|
|
432
480
|
}),
|
|
433
481
|
// @ts-expect-error Agent SDK canUseTool types are incomplete — our async handler is compatible at runtime
|
|
434
482
|
canUseTool: async (
|
|
435
483
|
toolName: string,
|
|
436
484
|
input: Record<string, unknown>
|
|
437
485
|
) => {
|
|
438
|
-
return handleToolPermission(taskId, toolName, input,
|
|
486
|
+
return handleToolPermission(taskId, toolName, input, ctx.canUseToolPolicy);
|
|
439
487
|
},
|
|
440
488
|
},
|
|
441
489
|
});
|
|
@@ -513,58 +561,36 @@ export async function resumeClaudeTask(taskId: string): Promise<void> {
|
|
|
513
561
|
|
|
514
562
|
try {
|
|
515
563
|
await prepareTaskOutputDirectory(taskId);
|
|
516
|
-
const
|
|
517
|
-
const payload = profile
|
|
518
|
-
? resolveProfileRuntimePayload(profile, "claude-code")
|
|
519
|
-
: null;
|
|
520
|
-
if (payload && !payload.supported) {
|
|
521
|
-
throw new Error(payload.reason ?? `Profile "${profile?.name}" is not supported on Claude Code`);
|
|
522
|
-
}
|
|
523
|
-
const systemPrompt = payload?.instructions ?? "";
|
|
524
|
-
const basePrompt = task.description || task.title;
|
|
525
|
-
const docContext = await buildDocumentContext(taskId);
|
|
526
|
-
const outputInstructions = buildTaskOutputInstructions(taskId);
|
|
527
|
-
const learnedCtx = getActiveLearnedContext(profileId);
|
|
528
|
-
const learnedCtxBlock = learnedCtx
|
|
529
|
-
? `## Learned Context\nPatterns and insights learned from previous tasks:\n\n${learnedCtx}`
|
|
530
|
-
: "";
|
|
531
|
-
const prompt = [systemPrompt, learnedCtxBlock, docContext, outputInstructions, basePrompt]
|
|
532
|
-
.filter(Boolean)
|
|
533
|
-
.join("\n\n");
|
|
534
|
-
|
|
535
|
-
// Resolve working directory: project's workingDirectory > process.cwd()
|
|
536
|
-
let cwd = process.cwd();
|
|
537
|
-
if (task.projectId) {
|
|
538
|
-
const [project] = await db
|
|
539
|
-
.select({ workingDirectory: projects.workingDirectory })
|
|
540
|
-
.from(projects)
|
|
541
|
-
.where(eq(projects.id, task.projectId));
|
|
542
|
-
if (project?.workingDirectory) {
|
|
543
|
-
cwd = project.workingDirectory;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
564
|
+
const ctx = await buildTaskQueryContext(task, profileId);
|
|
546
565
|
|
|
547
|
-
const policyForResume = payload?.canUseToolPolicy;
|
|
548
566
|
const authEnv = await getAuthEnv();
|
|
549
567
|
const response = query({
|
|
550
|
-
prompt,
|
|
568
|
+
prompt: ctx.userPrompt,
|
|
551
569
|
options: {
|
|
552
570
|
resume: task.sessionId,
|
|
553
571
|
abortController,
|
|
554
572
|
includePartialMessages: true,
|
|
555
|
-
cwd,
|
|
573
|
+
cwd: ctx.cwd,
|
|
556
574
|
env: buildClaudeSdkEnv(authEnv),
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
575
|
+
// F1: Use dedicated systemPrompt option with claude_code preset
|
|
576
|
+
systemPrompt: ctx.systemInstructions
|
|
577
|
+
? { type: "preset" as const, preset: "claude_code" as const, append: ctx.systemInstructions }
|
|
578
|
+
: { type: "preset" as const, preset: "claude_code" as const },
|
|
579
|
+
// F9: Bounded turn limit from profile or default
|
|
580
|
+
maxTurns: ctx.maxTurns,
|
|
581
|
+
// F4: Per-execution budget cap
|
|
582
|
+
maxBudgetUsd: DEFAULT_MAX_BUDGET_USD,
|
|
583
|
+
...(ctx.payload?.allowedTools && { allowedTools: ctx.payload.allowedTools }),
|
|
584
|
+
...(ctx.payload?.mcpServers &&
|
|
585
|
+
Object.keys(ctx.payload.mcpServers).length > 0 && {
|
|
586
|
+
mcpServers: ctx.payload.mcpServers,
|
|
561
587
|
}),
|
|
562
588
|
// @ts-expect-error Agent SDK canUseTool types are incomplete — our async handler is compatible at runtime
|
|
563
589
|
canUseTool: async (
|
|
564
590
|
toolName: string,
|
|
565
591
|
input: Record<string, unknown>
|
|
566
592
|
) => {
|
|
567
|
-
return handleToolPermission(taskId, toolName, input,
|
|
593
|
+
return handleToolPermission(taskId, toolName, input, ctx.canUseToolPolicy);
|
|
568
594
|
},
|
|
569
595
|
},
|
|
570
596
|
});
|