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.
Files changed (112) hide show
  1. package/README.md +58 -27
  2. package/package.json +3 -3
  3. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  4. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  5. package/src/__tests__/e2e/helpers.ts +286 -0
  6. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  7. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  8. package/src/__tests__/e2e/setup.ts +156 -0
  9. package/src/__tests__/e2e/single-task.test.ts +170 -0
  10. package/src/app/api/command-palette/recent/route.ts +41 -18
  11. package/src/app/api/context/batch/route.ts +44 -0
  12. package/src/app/api/permissions/presets/route.ts +80 -0
  13. package/src/app/api/playbook/status/route.ts +15 -0
  14. package/src/app/api/profiles/route.ts +23 -21
  15. package/src/app/api/settings/pricing/route.ts +15 -0
  16. package/src/app/costs/page.tsx +53 -43
  17. package/src/app/globals.css +0 -5
  18. package/src/app/playbook/[slug]/page.tsx +76 -0
  19. package/src/app/playbook/page.tsx +54 -0
  20. package/src/app/profiles/page.tsx +7 -4
  21. package/src/app/settings/page.tsx +2 -2
  22. package/src/app/tasks/page.tsx +5 -0
  23. package/src/components/costs/cost-dashboard.tsx +226 -320
  24. package/src/components/dashboard/activity-feed.tsx +6 -2
  25. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  26. package/src/components/notifications/notification-item.tsx +6 -3
  27. package/src/components/notifications/pending-approval-host.tsx +57 -11
  28. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  29. package/src/components/playbook/journey-card.tsx +110 -0
  30. package/src/components/playbook/playbook-action-button.tsx +22 -0
  31. package/src/components/playbook/playbook-browser.tsx +143 -0
  32. package/src/components/playbook/playbook-card.tsx +102 -0
  33. package/src/components/playbook/playbook-detail-view.tsx +223 -0
  34. package/src/components/playbook/playbook-homepage.tsx +142 -0
  35. package/src/components/playbook/playbook-toc.tsx +90 -0
  36. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  37. package/src/components/playbook/related-docs.tsx +30 -0
  38. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  39. package/src/components/profiles/context-proposal-review.tsx +7 -3
  40. package/src/components/profiles/learned-context-panel.tsx +116 -8
  41. package/src/components/profiles/profile-detail-view.tsx +7 -19
  42. package/src/components/profiles/profile-form-view.tsx +0 -22
  43. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  44. package/src/components/settings/api-key-form.tsx +5 -43
  45. package/src/components/settings/auth-config-section.tsx +10 -6
  46. package/src/components/settings/auth-status-badge.tsx +8 -0
  47. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  48. package/src/components/settings/connection-test-control.tsx +63 -0
  49. package/src/components/settings/permissions-section.tsx +85 -75
  50. package/src/components/settings/permissions-sections.tsx +24 -0
  51. package/src/components/settings/presets-section.tsx +159 -0
  52. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  53. package/src/components/shared/app-sidebar.tsx +2 -0
  54. package/src/components/shared/command-palette.tsx +30 -0
  55. package/src/components/shared/light-markdown.tsx +134 -0
  56. package/src/components/workflows/loop-status-view.tsx +8 -4
  57. package/src/components/workflows/workflow-status-view.tsx +16 -9
  58. package/src/lib/agents/__tests__/claude-agent.test.ts +7 -2
  59. package/src/lib/agents/__tests__/learned-context.test.ts +500 -0
  60. package/src/lib/agents/__tests__/pattern-extractor.test.ts +243 -0
  61. package/src/lib/agents/__tests__/sweep.test.ts +202 -0
  62. package/src/lib/agents/claude-agent.ts +104 -78
  63. package/src/lib/agents/learned-context.ts +32 -28
  64. package/src/lib/agents/learning-session.ts +234 -0
  65. package/src/lib/agents/pattern-extractor.ts +34 -64
  66. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  67. package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +0 -1
  68. package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +0 -1
  69. package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +0 -1
  70. package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +0 -1
  71. package/src/lib/agents/profiles/builtins/general/profile.yaml +0 -1
  72. package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +0 -1
  73. package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +0 -1
  74. package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +0 -1
  75. package/src/lib/agents/profiles/builtins/researcher/profile.yaml +0 -1
  76. package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +0 -1
  77. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +0 -1
  78. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +0 -1
  79. package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +0 -1
  80. package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +0 -1
  81. package/src/lib/agents/profiles/registry.ts +0 -1
  82. package/src/lib/agents/profiles/sort.ts +7 -0
  83. package/src/lib/agents/profiles/types.ts +0 -1
  84. package/src/lib/agents/runtime/catalog.ts +1 -1
  85. package/src/lib/agents/runtime/claude.ts +66 -0
  86. package/src/lib/constants/settings.ts +1 -0
  87. package/src/lib/constants/task-status.ts +6 -0
  88. package/src/lib/data/seed-data/profiles.ts +0 -3
  89. package/src/lib/db/schema.ts +3 -0
  90. package/src/lib/docs/adoption.ts +105 -0
  91. package/src/lib/docs/journey-tracker.ts +21 -0
  92. package/src/lib/docs/reader.ts +102 -0
  93. package/src/lib/docs/types.ts +54 -0
  94. package/src/lib/docs/usage-stage.ts +60 -0
  95. package/src/lib/notifications/actionable.ts +18 -10
  96. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  97. package/src/lib/settings/budget-guardrails.ts +213 -85
  98. package/src/lib/settings/permission-presets.ts +150 -0
  99. package/src/lib/settings/runtime-setup.ts +71 -0
  100. package/src/lib/usage/__tests__/ledger.test.ts +29 -5
  101. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  102. package/src/lib/usage/ledger.ts +4 -2
  103. package/src/lib/usage/pricing-registry.ts +570 -0
  104. package/src/lib/usage/pricing.ts +15 -41
  105. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  106. package/src/lib/utils/learned-context-history.ts +150 -0
  107. package/src/lib/validators/__tests__/profile.test.ts +0 -15
  108. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  109. package/src/lib/validators/profile.ts +0 -1
  110. package/src/lib/validators/settings.ts +3 -9
  111. package/src/lib/workflows/__tests__/engine.test.ts +2 -0
  112. 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 profile = getProfile(agentProfileId);
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
- ...(payload?.allowedTools && { allowedTools: payload.allowedTools }),
429
- ...(payload?.mcpServers &&
430
- Object.keys(payload.mcpServers).length > 0 && {
431
- mcpServers: payload.mcpServers,
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, policyForTask);
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 profile = getProfile(profileId);
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
- ...(payload?.allowedTools && { allowedTools: payload.allowedTools }),
558
- ...(payload?.mcpServers &&
559
- Object.keys(payload.mcpServers).length > 0 && {
560
- mcpServers: payload.mcpServers,
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, policyForResume);
593
+ return handleToolPermission(taskId, toolName, input, ctx.canUseToolPolicy);
568
594
  },
569
595
  },
570
596
  });