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.
- package/README.md +129 -47
- package/dist/cli.js +16 -24
- package/package.json +1 -1
- package/public/readme/cost-usage-list.png +0 -0
- package/public/readme/dashboard-bulk-select.png +0 -0
- package/public/readme/dashboard-card-edit.png +0 -0
- package/public/readme/dashboard-create-form-ai-applied.png +0 -0
- package/public/readme/dashboard-create-form-ai-assist.png +0 -0
- package/public/readme/dashboard-create-form-empty.png +0 -0
- package/public/readme/dashboard-create-form-filled.png +0 -0
- package/public/readme/dashboard-filtered.png +0 -0
- package/public/readme/dashboard-list.png +0 -0
- package/public/readme/dashboard-sorted.png +0 -0
- package/public/readme/dashboard-workflow-confirm.png +0 -0
- package/public/readme/documents-grid.png +0 -0
- package/public/readme/documents-list.png +0 -0
- package/public/readme/home-below-fold.png +0 -0
- package/public/readme/home-list.png +0 -0
- package/public/readme/inbox-list.png +0 -0
- package/public/readme/monitor-list.png +0 -0
- package/public/readme/profiles-list.png +0 -0
- package/public/readme/projects-detail.png +0 -0
- package/public/readme/projects-list.png +0 -0
- package/public/readme/schedules-list.png +0 -0
- package/public/readme/settings-list.png +0 -0
- package/public/readme/workflows-list.png +0 -0
- package/src/app/api/documents/route.ts +21 -2
- package/src/app/api/tasks/route.ts +16 -3
- package/src/app/api/uploads/route.ts +17 -3
- package/src/app/api/workflows/from-assist/route.ts +143 -0
- package/src/app/dashboard/page.tsx +24 -2
- package/src/app/globals.css +34 -0
- package/src/app/tasks/new/page.tsx +10 -2
- package/src/app/workflows/from-assist/page.tsx +35 -0
- package/src/components/projects/project-card.tsx +47 -35
- package/src/components/tasks/__tests__/kanban-board-persistence.test.tsx +124 -0
- package/src/components/tasks/__tests__/task-create-panel.test.tsx +58 -0
- package/src/components/tasks/ai-assist-panel.tsx +80 -21
- package/src/components/tasks/kanban-board.tsx +201 -5
- package/src/components/tasks/kanban-column.tsx +156 -5
- package/src/components/tasks/task-card.tsx +201 -44
- package/src/components/tasks/task-create-panel.tsx +42 -2
- package/src/components/tasks/task-detail-view.tsx +58 -1
- package/src/components/tasks/task-edit-dialog.tsx +277 -0
- package/src/components/workflows/workflow-confirmation-view.tsx +447 -0
- package/src/hooks/__tests__/use-persisted-state.test.ts +57 -0
- package/src/hooks/use-persisted-state.ts +40 -0
- package/src/lib/agents/claude-agent.ts +17 -7
- package/src/lib/agents/profiles/__tests__/suggest.test.ts +67 -0
- package/src/lib/agents/profiles/suggest.ts +36 -0
- package/src/lib/agents/runtime/claude-sdk.ts +20 -6
- package/src/lib/agents/runtime/claude.ts +59 -11
- package/src/lib/agents/runtime/openai-codex.ts +14 -1
- package/src/lib/agents/runtime/task-assist-types.ts +12 -2
- package/src/lib/data/__tests__/clear.test.ts +42 -0
- package/src/lib/data/clear.ts +3 -0
- package/src/lib/db/bootstrap.ts +17 -32
- package/src/lib/documents/cleanup.ts +3 -2
- package/src/lib/notifications/permissions.ts +7 -1
- package/src/lib/workflows/__tests__/assist-builder.test.ts +255 -0
- package/src/lib/workflows/assist-builder.ts +248 -0
- package/src/lib/workflows/assist-session.ts +78 -0
- 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
|
-
*
|
|
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>
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
29
|
-
- "
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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:
|
|
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
|
+
});
|
package/src/lib/data/clear.ts
CHANGED
|
@@ -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
|
}
|
package/src/lib/db/bootstrap.ts
CHANGED
|
@@ -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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
});
|