stagent 0.1.7 → 0.1.10

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 (63) hide show
  1. package/README.md +129 -47
  2. package/dist/cli.js +16 -24
  3. package/package.json +1 -1
  4. package/public/readme/cost-usage-list.png +0 -0
  5. package/public/readme/dashboard-bulk-select.png +0 -0
  6. package/public/readme/dashboard-card-edit.png +0 -0
  7. package/public/readme/dashboard-create-form-ai-applied.png +0 -0
  8. package/public/readme/dashboard-create-form-ai-assist.png +0 -0
  9. package/public/readme/dashboard-create-form-empty.png +0 -0
  10. package/public/readme/dashboard-create-form-filled.png +0 -0
  11. package/public/readme/dashboard-filtered.png +0 -0
  12. package/public/readme/dashboard-list.png +0 -0
  13. package/public/readme/dashboard-sorted.png +0 -0
  14. package/public/readme/dashboard-workflow-confirm.png +0 -0
  15. package/public/readme/documents-grid.png +0 -0
  16. package/public/readme/documents-list.png +0 -0
  17. package/public/readme/home-below-fold.png +0 -0
  18. package/public/readme/home-list.png +0 -0
  19. package/public/readme/inbox-list.png +0 -0
  20. package/public/readme/monitor-list.png +0 -0
  21. package/public/readme/profiles-list.png +0 -0
  22. package/public/readme/projects-detail.png +0 -0
  23. package/public/readme/projects-list.png +0 -0
  24. package/public/readme/schedules-list.png +0 -0
  25. package/public/readme/settings-list.png +0 -0
  26. package/public/readme/workflows-list.png +0 -0
  27. package/src/app/api/documents/route.ts +21 -2
  28. package/src/app/api/tasks/route.ts +16 -3
  29. package/src/app/api/uploads/route.ts +17 -3
  30. package/src/app/api/workflows/from-assist/route.ts +143 -0
  31. package/src/app/dashboard/page.tsx +24 -2
  32. package/src/app/globals.css +34 -0
  33. package/src/app/tasks/new/page.tsx +10 -2
  34. package/src/app/workflows/from-assist/page.tsx +35 -0
  35. package/src/components/projects/project-card.tsx +47 -35
  36. package/src/components/tasks/__tests__/kanban-board-persistence.test.tsx +124 -0
  37. package/src/components/tasks/__tests__/task-create-panel.test.tsx +58 -0
  38. package/src/components/tasks/ai-assist-panel.tsx +80 -21
  39. package/src/components/tasks/kanban-board.tsx +201 -5
  40. package/src/components/tasks/kanban-column.tsx +156 -5
  41. package/src/components/tasks/task-card.tsx +201 -44
  42. package/src/components/tasks/task-create-panel.tsx +42 -2
  43. package/src/components/tasks/task-detail-view.tsx +58 -1
  44. package/src/components/tasks/task-edit-dialog.tsx +277 -0
  45. package/src/components/workflows/workflow-confirmation-view.tsx +447 -0
  46. package/src/hooks/__tests__/use-persisted-state.test.ts +57 -0
  47. package/src/hooks/use-persisted-state.ts +40 -0
  48. package/src/lib/agents/claude-agent.ts +17 -7
  49. package/src/lib/agents/profiles/__tests__/suggest.test.ts +67 -0
  50. package/src/lib/agents/profiles/suggest.ts +36 -0
  51. package/src/lib/agents/runtime/claude-sdk.ts +20 -6
  52. package/src/lib/agents/runtime/claude.ts +59 -11
  53. package/src/lib/agents/runtime/openai-codex.ts +14 -1
  54. package/src/lib/agents/runtime/task-assist-types.ts +12 -2
  55. package/src/lib/data/__tests__/clear.test.ts +42 -0
  56. package/src/lib/data/clear.ts +3 -0
  57. package/src/lib/db/bootstrap.ts +17 -32
  58. package/src/lib/documents/cleanup.ts +3 -2
  59. package/src/lib/notifications/permissions.ts +7 -1
  60. package/src/lib/workflows/__tests__/assist-builder.test.ts +255 -0
  61. package/src/lib/workflows/assist-builder.ts +248 -0
  62. package/src/lib/workflows/assist-session.ts +78 -0
  63. package/src/lib/workflows/engine.ts +48 -3
@@ -1,12 +1,26 @@
1
1
  /**
2
2
  * Build the environment for the Claude Agent SDK subprocess.
3
- * Returns undefined when no changes are needed.
3
+ *
4
+ * Always strips CLAUDECODE (prevents nested-session issues) and
5
+ * ANTHROPIC_API_KEY (prevents SDK from using API-key auth when
6
+ * OAuth mode is intended).
7
+ *
8
+ * - API-key mode: authEnv is provided → key gets merged back in via spread.
9
+ * - OAuth mode: authEnv is undefined → key stays stripped, SDK falls
10
+ * through to cached OAuth tokens from `claude login`.
4
11
  */
5
12
  export function buildClaudeSdkEnv(
6
13
  authEnv?: Record<string, string>
7
- ): Record<string, string> | undefined {
8
- const isNested = "CLAUDECODE" in process.env;
9
- if (!authEnv && !isNested) return undefined;
10
- const { CLAUDECODE, ...cleanEnv } = process.env as Record<string, string>;
11
- return { ...cleanEnv, ...authEnv };
14
+ ): Record<string, string> {
15
+ const { CLAUDECODE, ANTHROPIC_API_KEY, ...cleanEnv } =
16
+ process.env as Record<string, string>;
17
+
18
+ if (authEnv) {
19
+ // API key mode — merge the provided key into clean env
20
+ return { ...cleanEnv, ...authEnv };
21
+ }
22
+
23
+ // OAuth mode — return env WITHOUT ANTHROPIC_API_KEY
24
+ // so the SDK subprocess uses cached OAuth tokens from Claude CLI
25
+ return cleanEnv;
12
26
  }
@@ -4,7 +4,7 @@ import { tasks } from "@/lib/db/schema";
4
4
  import { eq } from "drizzle-orm";
5
5
  import { updateAuthStatus, getAuthEnv } from "@/lib/settings/auth";
6
6
  import { getExecution, removeExecution } from "@/lib/agents/execution-manager";
7
- import { getProfile } from "@/lib/agents/profiles/registry";
7
+ import { getProfile, listProfiles } from "@/lib/agents/profiles/registry";
8
8
  import { resolveProfileRuntimePayload } from "@/lib/agents/profiles/compatibility";
9
9
  import { executeClaudeTask, resumeClaudeTask } from "@/lib/agents/claude-agent";
10
10
  import { getRuntimeCapabilities, getRuntimeCatalogEntry } from "./catalog";
@@ -23,13 +23,41 @@ import {
23
23
  type UsageSnapshot,
24
24
  } from "@/lib/usage/ledger";
25
25
 
26
- const TASK_ASSIST_SYSTEM_PROMPT = `You are an AI task definition assistant. Analyze the given task and return ONLY a JSON object (no markdown, no code fences) with:
26
+ function buildTaskAssistSystemPrompt(profileIds: string[]): string {
27
+ const profileList = profileIds.length > 0
28
+ ? `Available agent profiles: ${profileIds.join(", ")}\nUse "auto" if unsure which profile fits a step.`
29
+ : `No explicit profiles available. Use "auto" for suggestedProfile.`;
30
+
31
+ return `You are an AI task definition assistant. Analyze the given task and return ONLY a JSON object (no markdown, no code fences) with:
27
32
  - "improvedDescription": A clearer version of the task for an AI agent to execute
28
- - "breakdown": Array of {title, description} sub-tasks if complex (empty array if simple)
29
- - "recommendedPattern": "single", "sequence", "planner-executor", or "checkpoint"
33
+ - "breakdown": Array of step objects if complex (empty array if simple). Each step: {title, description, suggestedProfile?, requiresApproval?, dependsOn?}
34
+ - "suggestedProfile": one of the available profile IDs or "auto"
35
+ - "requiresApproval": true if the step involves irreversible actions needing human review
36
+ - "dependsOn": array of step indices (0-based) this step depends on (for parallel/swarm patterns)
37
+ - "recommendedPattern": one of "single", "sequence", "planner-executor", "checkpoint", "parallel", "loop", "swarm"
38
+ - "sequence": steps run one after another in order
39
+ - "planner-executor": first step plans, remaining steps execute the plan
40
+ - "checkpoint": like sequence but certain steps pause for human approval
41
+ - "parallel": independent steps run concurrently, a final synthesis step merges results (use dependsOn to mark the synthesis step)
42
+ - "loop": a single step repeats iteratively until a goal is met (include suggestedLoopConfig)
43
+ - "swarm": first step is the mayor (coordinator), middle steps are workers (run in parallel), last step is the refinery (merges results)
30
44
  - "complexity": "simple", "moderate", or "complex"
31
45
  - "needsCheckpoint": true if irreversible actions or needs human review
32
- - "reasoning": Brief explanation`;
46
+ - "reasoning": Brief explanation of why you chose this pattern
47
+ - "suggestedLoopConfig": {maxIterations, timeBudgetMs?} — only for loop pattern
48
+ - "suggestedSwarmConfig": {workerConcurrencyLimit?} — only for swarm pattern
49
+
50
+ ${profileList}
51
+
52
+ Pattern selection guide:
53
+ - Use "single" for simple, atomic tasks
54
+ - Use "sequence" for ordered multi-step work where each step builds on the previous
55
+ - Use "planner-executor" when the task needs analysis before action
56
+ - Use "checkpoint" when steps involve deployments, deletions, or other irreversible actions
57
+ - Use "parallel" when sub-tasks are independent and can run concurrently (research, analysis)
58
+ - Use "loop" for iterative refinement (code review cycles, optimization passes)
59
+ - Use "swarm" for complex tasks needing multiple specialized agents coordinated by a lead`;
60
+ }
33
61
 
34
62
  async function collectResultText(
35
63
  response: AsyncIterable<Record<string, unknown>>
@@ -39,11 +67,20 @@ async function collectResultText(
39
67
 
40
68
  for await (const raw of response) {
41
69
  usage = mergeUsageSnapshot(usage, extractUsageSnapshot(raw));
42
- if (raw.type === "result" && "result" in raw) {
43
- resultText =
44
- typeof raw.result === "string"
45
- ? raw.result
46
- : JSON.stringify(raw.result);
70
+
71
+ if (raw.type === "content_block_delta") {
72
+ const delta = raw.delta as Record<string, unknown> | undefined;
73
+ if (delta?.type === "text_delta" && typeof delta.text === "string") {
74
+ resultText += delta.text;
75
+ }
76
+ } else if (raw.type === "result" && "result" in raw) {
77
+ if (raw.is_error) {
78
+ throw new Error(typeof raw.result === "string" ? raw.result : "Agent SDK returned an error");
79
+ }
80
+ const result = raw.result;
81
+ if (typeof result === "string" && result.length > 0) {
82
+ resultText = result;
83
+ }
47
84
  break;
48
85
  }
49
86
  }
@@ -226,16 +263,25 @@ async function runClaudeTaskAssist(
226
263
  .join("\n");
227
264
 
228
265
  const authEnv = await getAuthEnv();
229
- const prompt = `${TASK_ASSIST_SYSTEM_PROMPT}\n\n${userMessage}`;
266
+ const profileIds = listProfiles().map((p) => p.id);
267
+ const systemPrompt = buildTaskAssistSystemPrompt(profileIds);
268
+ const prompt = `${systemPrompt}\n\n${userMessage}`;
230
269
  const startedAt = new Date();
231
270
  let usage: UsageSnapshot = {};
232
271
 
272
+ const abortController = new AbortController();
273
+ const timeout = setTimeout(() => abortController.abort(), 30_000);
274
+
233
275
  try {
234
276
  const response = query({
235
277
  prompt,
236
278
  options: {
279
+ abortController,
280
+ includePartialMessages: true,
237
281
  cwd: process.cwd(),
238
282
  env: buildClaudeSdkEnv(authEnv),
283
+ allowedTools: [], // No tool use — pure text completion
284
+ maxTurns: 1, // Single turn only — no agentic loop
239
285
  },
240
286
  });
241
287
 
@@ -283,6 +329,8 @@ async function runClaudeTaskAssist(
283
329
  finishedAt: new Date(),
284
330
  });
285
331
  throw error;
332
+ } finally {
333
+ clearTimeout(timeout);
286
334
  }
287
335
  }
288
336
 
@@ -643,7 +643,13 @@ async function runAssistTurn({
643
643
  ephemeral: true,
644
644
  })) as { thread: { id: string } };
645
645
 
646
+ const ASSIST_TIMEOUT_MS = 60_000;
647
+
646
648
  const completion = new Promise<void>((resolve, reject) => {
649
+ client!.onProcessError = (error: Error) => {
650
+ reject(new Error(`Codex process died: ${error.message}`));
651
+ };
652
+
647
653
  client!.onNotification = (notification: JsonRpcLikeNotification) => {
648
654
  const params = asRecord(notification.params) ?? {};
649
655
  applyUsageSnapshot(usage, params);
@@ -669,6 +675,13 @@ async function runAssistTurn({
669
675
  };
670
676
  });
671
677
 
678
+ const timeout = new Promise<never>((_, reject) => {
679
+ setTimeout(
680
+ () => reject(new Error("Codex task assist timed out after 60s")),
681
+ ASSIST_TIMEOUT_MS
682
+ );
683
+ });
684
+
672
685
  await client.request("turn/start", {
673
686
  threadId: threadResponse.thread.id,
674
687
  input: buildTurnInput(prompt),
@@ -676,7 +689,7 @@ async function runAssistTurn({
676
689
  outputSchema: TASK_ASSIST_OUTPUT_SCHEMA,
677
690
  });
678
691
 
679
- await completion;
692
+ await Promise.race([completion, timeout]);
680
693
 
681
694
  return { text: text.trim(), usage };
682
695
  } finally {
@@ -1,8 +1,18 @@
1
+ export interface TaskAssistBreakdownStep {
2
+ title: string;
3
+ description: string;
4
+ suggestedProfile?: string;
5
+ requiresApproval?: boolean;
6
+ dependsOn?: number[];
7
+ }
8
+
1
9
  export interface TaskAssistResponse {
2
10
  improvedDescription: string;
3
- breakdown: { title: string; description: string }[];
4
- recommendedPattern: "single" | "sequence" | "planner-executor" | "checkpoint";
11
+ breakdown: TaskAssistBreakdownStep[];
12
+ recommendedPattern: "single" | "sequence" | "planner-executor" | "checkpoint" | "parallel" | "loop" | "swarm";
5
13
  complexity: "simple" | "moderate" | "complex";
6
14
  needsCheckpoint: boolean;
7
15
  reasoning: string;
16
+ suggestedLoopConfig?: { maxIterations: number; timeBudgetMs?: number };
17
+ suggestedSwarmConfig?: { workerConcurrencyLimit?: number };
8
18
  }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import * as schema from "@/lib/db/schema";
5
+
6
+ /**
7
+ * Safety-net test: every table exported from schema.ts must appear in clear.ts
8
+ * (except `settings`, which is intentionally preserved across clears).
9
+ *
10
+ * When you add a new table to schema.ts, this test will fail until you add a
11
+ * corresponding db.delete() call to clear.ts in the correct FK-safe order.
12
+ */
13
+ describe("clearAllData coverage", () => {
14
+ const INTENTIONALLY_PRESERVED = ["settings"];
15
+
16
+ it("deletes every schema table (except settings)", () => {
17
+ const clearSource = readFileSync(
18
+ join(__dirname, "..", "clear.ts"),
19
+ "utf-8"
20
+ );
21
+
22
+ // Collect all sqliteTable exports from schema
23
+ const tableExports = Object.entries(schema)
24
+ .filter(
25
+ ([, value]) =>
26
+ value != null &&
27
+ typeof value === "object" &&
28
+ "getSQL" in (value as Record<string, unknown>)
29
+ )
30
+ .map(([name]) => name);
31
+
32
+ expect(tableExports.length).toBeGreaterThan(0);
33
+
34
+ const missing = tableExports.filter(
35
+ (name) =>
36
+ !INTENTIONALLY_PRESERVED.includes(name) &&
37
+ !clearSource.includes(`db.delete(${name})`)
38
+ );
39
+
40
+ expect(missing, `Tables missing from clear.ts: ${missing.join(", ")}`).toEqual([]);
41
+ });
42
+ });
@@ -3,6 +3,7 @@ import {
3
3
  agentLogs,
4
4
  notifications,
5
5
  documents,
6
+ learnedContext,
6
7
  tasks,
7
8
  workflows,
8
9
  schedules,
@@ -31,6 +32,7 @@ export function clearAllData() {
31
32
  const logsDeleted = db.delete(agentLogs).run().changes;
32
33
  const notificationsDeleted = db.delete(notifications).run().changes;
33
34
  const documentsDeleted = db.delete(documents).run().changes;
35
+ const learnedContextDeleted = db.delete(learnedContext).run().changes;
34
36
  const tasksDeleted = db.delete(tasks).run().changes;
35
37
  const workflowsDeleted = db.delete(workflows).run().changes;
36
38
  const schedulesDeleted = db.delete(schedules).run().changes;
@@ -58,6 +60,7 @@ export function clearAllData() {
58
60
  agentLogs: logsDeleted,
59
61
  notifications: notificationsDeleted,
60
62
  documents: documentsDeleted,
63
+ learnedContext: learnedContextDeleted,
61
64
  files: filesDeleted,
62
65
  };
63
66
  }
@@ -193,44 +193,29 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
193
193
  CREATE INDEX IF NOT EXISTS idx_learned_context_change_type ON learned_context(change_type);
194
194
  `);
195
195
 
196
- try {
197
- sqlite.exec(`ALTER TABLE tasks ADD COLUMN agent_profile TEXT;`);
198
- } catch {
199
- // Column already exists.
200
- }
196
+ const addColumnIfMissing = (ddl: string) => {
197
+ try {
198
+ sqlite.exec(ddl);
199
+ } catch (err: unknown) {
200
+ const msg = err instanceof Error ? err.message : String(err);
201
+ if (!msg.includes("duplicate column")) {
202
+ console.error("[bootstrap] ALTER TABLE failed:", msg);
203
+ }
204
+ }
205
+ };
206
+
207
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN agent_profile TEXT;`);
201
208
  sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_agent_profile ON tasks(agent_profile);`);
202
209
 
203
- try {
204
- sqlite.exec(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
205
- } catch {
206
- // Column already exists.
207
- }
210
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
208
211
  sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id);`);
209
212
 
210
- try {
211
- sqlite.exec(`ALTER TABLE tasks ADD COLUMN schedule_id TEXT REFERENCES schedules(id);`);
212
- } catch {
213
- // Column already exists.
214
- }
213
+ addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN schedule_id TEXT REFERENCES schedules(id);`);
215
214
  sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_schedule_id ON tasks(schedule_id);`);
216
215
 
217
- try {
218
- sqlite.exec(`ALTER TABLE projects ADD COLUMN working_directory TEXT;`);
219
- } catch {
220
- // Column already exists.
221
- }
222
-
223
- try {
224
- sqlite.exec(`ALTER TABLE schedules ADD COLUMN assigned_agent TEXT;`);
225
- } catch {
226
- // Column already exists.
227
- }
228
-
229
- try {
230
- sqlite.exec(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
231
- } catch {
232
- // Column already exists.
233
- }
216
+ addColumnIfMissing(`ALTER TABLE projects ADD COLUMN working_directory TEXT;`);
217
+ addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN assigned_agent TEXT;`);
218
+ addColumnIfMissing(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
234
219
  }
235
220
 
236
221
  export function hasLegacyStagentTables(sqlite: Database.Database): boolean {
@@ -42,8 +42,9 @@ export async function cleanupOrphanedUploads(): Promise<{
42
42
  errors.push(`${filename}: ${err instanceof Error ? err.message : "unknown error"}`);
43
43
  }
44
44
  }
45
- } catch {
46
- // Upload directory may not exist yet
45
+ } catch (err) {
46
+ // Upload directory may not exist yet — log for visibility
47
+ console.error("[cleanup] Failed to read upload directory:", err);
47
48
  }
48
49
 
49
50
  return { deleted, errors };
@@ -23,7 +23,8 @@ export function parseNotificationToolInput(
23
23
  return parsed && typeof parsed === "object"
24
24
  ? (parsed as PermissionToolInput)
25
25
  : null;
26
- } catch {
26
+ } catch (err) {
27
+ console.error("[permissions] Failed to parse notification tool input:", err);
27
28
  return null;
28
29
  }
29
30
  }
@@ -148,6 +149,11 @@ export function getPermissionDetailEntries(
148
149
  export function getPermissionResponseLabel(response: string | null): string | null {
149
150
  if (!response) return null;
150
151
 
152
+ // Handle legacy plain-string responses (pre-JSON format)
153
+ const legacy = response.toLowerCase();
154
+ if (legacy === "approved" || legacy === "allowed") return "Allowed";
155
+ if (legacy === "denied" || legacy === "rejected") return "Denied";
156
+
151
157
  try {
152
158
  const parsed = JSON.parse(response) as {
153
159
  behavior?: "allow" | "deny";
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildWorkflowDefinitionFromAssist } from "../assist-builder";
3
+ import type { TaskAssistResponse } from "@/lib/agents/runtime/task-assist-types";
4
+
5
+ const MAIN_TASK = {
6
+ title: "Build Auth System",
7
+ description: "Implement authentication with OAuth2",
8
+ agentProfile: "general",
9
+ };
10
+
11
+ function makeAssistResponse(
12
+ overrides: Partial<TaskAssistResponse> = {}
13
+ ): TaskAssistResponse {
14
+ return {
15
+ improvedDescription: "Build a complete auth system",
16
+ breakdown: [
17
+ { title: "Set up middleware", description: "Create auth middleware" },
18
+ { title: "Create endpoints", description: "Build user API endpoints" },
19
+ { title: "Write tests", description: "Integration tests for auth" },
20
+ ],
21
+ recommendedPattern: "sequence",
22
+ complexity: "complex",
23
+ needsCheckpoint: false,
24
+ reasoning: "Multi-step ordered work",
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ describe("buildWorkflowDefinitionFromAssist", () => {
30
+ describe("sequence pattern", () => {
31
+ it("creates a sequence workflow with main task as step 1", () => {
32
+ const result = buildWorkflowDefinitionFromAssist({
33
+ mainTask: MAIN_TASK,
34
+ assistResponse: makeAssistResponse(),
35
+ });
36
+
37
+ expect(result.pattern).toBe("sequence");
38
+ expect(result.steps).toHaveLength(4); // main + 3 breakdown
39
+ expect(result.steps[0].name).toBe("Build Auth System");
40
+ expect(result.steps[1].name).toBe("Set up middleware");
41
+ expect(result.steps[3].name).toBe("Write tests");
42
+ });
43
+
44
+ it("assigns profiles from main task and suggestions", () => {
45
+ const result = buildWorkflowDefinitionFromAssist({
46
+ mainTask: MAIN_TASK,
47
+ assistResponse: makeAssistResponse({
48
+ breakdown: [
49
+ { title: "Research", description: "Research patterns", suggestedProfile: "researcher" },
50
+ { title: "Code", description: "Write code" },
51
+ ],
52
+ }),
53
+ });
54
+
55
+ expect(result.steps[0].agentProfile).toBe("general"); // from mainTask
56
+ expect(result.steps[1].agentProfile).toBe("researcher"); // from suggestion
57
+ expect(result.steps[2].agentProfile).toBeUndefined(); // no suggestion = undefined
58
+ });
59
+ });
60
+
61
+ describe("checkpoint pattern", () => {
62
+ it("preserves requiresApproval on steps", () => {
63
+ const result = buildWorkflowDefinitionFromAssist({
64
+ mainTask: MAIN_TASK,
65
+ assistResponse: makeAssistResponse({
66
+ recommendedPattern: "checkpoint",
67
+ breakdown: [
68
+ { title: "Plan", description: "Plan deployment", requiresApproval: true },
69
+ { title: "Deploy", description: "Execute deployment" },
70
+ ],
71
+ }),
72
+ });
73
+
74
+ expect(result.pattern).toBe("checkpoint");
75
+ expect(result.steps[1].requiresApproval).toBe(true);
76
+ expect(result.steps[2].requiresApproval).toBeUndefined();
77
+ });
78
+ });
79
+
80
+ describe("parallel pattern", () => {
81
+ it("auto-generates synthesis step when none provided", () => {
82
+ const result = buildWorkflowDefinitionFromAssist({
83
+ mainTask: MAIN_TASK,
84
+ assistResponse: makeAssistResponse({
85
+ recommendedPattern: "parallel",
86
+ breakdown: [
87
+ { title: "Branch A", description: "Research area A" },
88
+ { title: "Branch B", description: "Research area B" },
89
+ ],
90
+ }),
91
+ });
92
+
93
+ expect(result.pattern).toBe("parallel");
94
+ // main + 2 branches + auto-synthesis = 4
95
+ expect(result.steps).toHaveLength(4);
96
+ expect(result.steps[3].name).toBe("Synthesize results");
97
+ expect(result.steps[3].dependsOn).toEqual(["step_1", "step_2", "step_3"]);
98
+ });
99
+
100
+ it("preserves explicit synthesis step with dependsOn", () => {
101
+ const result = buildWorkflowDefinitionFromAssist({
102
+ mainTask: MAIN_TASK,
103
+ assistResponse: makeAssistResponse({
104
+ recommendedPattern: "parallel",
105
+ breakdown: [
106
+ { title: "Branch A", description: "Research A" },
107
+ { title: "Merge", description: "Merge results", dependsOn: [0, 1] },
108
+ ],
109
+ }),
110
+ });
111
+
112
+ // main + Branch A + Merge = 3 (no auto-synthesis because dependsOn exists)
113
+ expect(result.steps).toHaveLength(3);
114
+ expect(result.steps[2].dependsOn).toEqual(["step_1", "step_2"]);
115
+ });
116
+ });
117
+
118
+ describe("loop pattern", () => {
119
+ it("creates single-step loop with config", () => {
120
+ const result = buildWorkflowDefinitionFromAssist({
121
+ mainTask: MAIN_TASK,
122
+ assistResponse: makeAssistResponse({
123
+ recommendedPattern: "loop",
124
+ suggestedLoopConfig: { maxIterations: 3, timeBudgetMs: 60000 },
125
+ }),
126
+ });
127
+
128
+ expect(result.pattern).toBe("loop");
129
+ expect(result.steps).toHaveLength(1);
130
+ expect(result.loopConfig?.maxIterations).toBe(3);
131
+ expect(result.loopConfig?.timeBudgetMs).toBe(60000);
132
+ });
133
+
134
+ it("defaults to 5 iterations", () => {
135
+ const result = buildWorkflowDefinitionFromAssist({
136
+ mainTask: MAIN_TASK,
137
+ assistResponse: makeAssistResponse({ recommendedPattern: "loop" }),
138
+ });
139
+
140
+ expect(result.loopConfig?.maxIterations).toBe(5);
141
+ });
142
+
143
+ it("applies loop config overrides", () => {
144
+ const result = buildWorkflowDefinitionFromAssist({
145
+ mainTask: MAIN_TASK,
146
+ assistResponse: makeAssistResponse({
147
+ recommendedPattern: "loop",
148
+ suggestedLoopConfig: { maxIterations: 3 },
149
+ }),
150
+ overrides: { loopConfig: { maxIterations: 10 } },
151
+ });
152
+
153
+ expect(result.loopConfig?.maxIterations).toBe(10);
154
+ });
155
+ });
156
+
157
+ describe("swarm pattern", () => {
158
+ it("creates mayor/workers/refinery structure", () => {
159
+ const result = buildWorkflowDefinitionFromAssist({
160
+ mainTask: MAIN_TASK,
161
+ assistResponse: makeAssistResponse({
162
+ recommendedPattern: "swarm",
163
+ breakdown: [
164
+ { title: "Worker 1", description: "Task 1" },
165
+ { title: "Worker 2", description: "Task 2" },
166
+ ],
167
+ }),
168
+ });
169
+
170
+ expect(result.pattern).toBe("swarm");
171
+ // mayor + 2 workers + refinery = 4
172
+ expect(result.steps).toHaveLength(4);
173
+ expect(result.steps[0].name).toBe("Build Auth System"); // mayor
174
+ expect(result.steps[3].name).toBe("Refine and merge results"); // refinery
175
+ expect(result.swarmConfig?.workerConcurrencyLimit).toBe(2);
176
+ });
177
+
178
+ it("applies swarm config overrides", () => {
179
+ const result = buildWorkflowDefinitionFromAssist({
180
+ mainTask: MAIN_TASK,
181
+ assistResponse: makeAssistResponse({
182
+ recommendedPattern: "swarm",
183
+ breakdown: [
184
+ { title: "W1", description: "T1" },
185
+ { title: "W2", description: "T2" },
186
+ ],
187
+ suggestedSwarmConfig: { workerConcurrencyLimit: 1 },
188
+ }),
189
+ overrides: { swarmConfig: { workerConcurrencyLimit: 2 } },
190
+ });
191
+
192
+ expect(result.swarmConfig?.workerConcurrencyLimit).toBe(2);
193
+ });
194
+ });
195
+
196
+ describe("pattern override", () => {
197
+ it("overrides AI-recommended pattern", () => {
198
+ const result = buildWorkflowDefinitionFromAssist({
199
+ mainTask: MAIN_TASK,
200
+ assistResponse: makeAssistResponse({ recommendedPattern: "sequence" }),
201
+ overrides: { pattern: "checkpoint" },
202
+ });
203
+
204
+ expect(result.pattern).toBe("checkpoint");
205
+ });
206
+ });
207
+
208
+ describe("step overrides", () => {
209
+ it("applies partial step overrides", () => {
210
+ const result = buildWorkflowDefinitionFromAssist({
211
+ mainTask: MAIN_TASK,
212
+ assistResponse: makeAssistResponse(),
213
+ overrides: {
214
+ steps: [
215
+ undefined,
216
+ { agentProfile: "code-reviewer" },
217
+ ] as Partial<import("../types").WorkflowStep>[],
218
+ },
219
+ });
220
+
221
+ expect(result.steps[1].agentProfile).toBe("code-reviewer");
222
+ });
223
+ });
224
+
225
+ describe("validation", () => {
226
+ it("throws on invalid definition", () => {
227
+ expect(() =>
228
+ buildWorkflowDefinitionFromAssist({
229
+ mainTask: MAIN_TASK,
230
+ assistResponse: makeAssistResponse({
231
+ recommendedPattern: "loop",
232
+ // Missing loopConfig
233
+ }),
234
+ overrides: { loopConfig: { maxIterations: 0 } },
235
+ })
236
+ ).toThrow("Invalid workflow definition");
237
+ });
238
+ });
239
+
240
+ describe("auto profile handling", () => {
241
+ it('treats "auto" suggestedProfile as undefined', () => {
242
+ const result = buildWorkflowDefinitionFromAssist({
243
+ mainTask: { ...MAIN_TASK, agentProfile: undefined },
244
+ assistResponse: makeAssistResponse({
245
+ breakdown: [
246
+ { title: "Step", description: "Do thing", suggestedProfile: "auto" },
247
+ ],
248
+ }),
249
+ });
250
+
251
+ expect(result.steps[0].agentProfile).toBeUndefined();
252
+ expect(result.steps[1].agentProfile).toBeUndefined();
253
+ });
254
+ });
255
+ });