@desplega.ai/agent-swarm 1.88.0 → 1.90.0
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 +7 -0
- package/openapi.json +41 -1
- package/package.json +3 -2
- package/plugin/skills/composio/SKILL.md +173 -0
- package/plugin/skills/composio-gmail/SKILL.md +83 -0
- package/plugin/skills/composio-google-calendar/SKILL.md +81 -0
- package/plugin/skills/composio-google-docs/SKILL.md +71 -0
- package/src/be/db.ts +353 -2
- package/src/be/migrations/081_metrics.sql +39 -0
- package/src/be/migrations/082_user_audit_fields.sql +120 -0
- package/src/be/modelsdev-cache.json +3413 -1423
- package/src/be/seed-skills/index.ts +7 -0
- package/src/cli.tsx +18 -0
- package/src/commands/runner.ts +153 -22
- package/src/commands/x.ts +118 -0
- package/src/github/handlers.ts +40 -1
- package/src/heartbeat/heartbeat.ts +80 -12
- package/src/http/active-sessions.ts +32 -1
- package/src/http/auth.ts +36 -0
- package/src/http/core.ts +20 -16
- package/src/http/db-query.ts +20 -0
- package/src/http/index.ts +2 -0
- package/src/http/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/tasks.ts +6 -1
- package/src/http/workflows.ts +5 -1
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +8 -0
- package/src/prompts/session-templates.ts +23 -0
- package/src/providers/opencode-adapter.ts +22 -6
- package/src/server.ts +10 -1
- package/src/tasks/worker-follow-up.ts +19 -1
- package/src/tests/base-prompt.test.ts +35 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/core-auth.test.ts +8 -1
- package/src/tests/events-http.test.ts +6 -2
- package/src/tests/github-handlers-cancel-config.test.ts +262 -0
- package/src/tests/heartbeat-supersede-resume.test.ts +91 -1
- package/src/tests/heartbeat.test.ts +84 -3
- package/src/tests/http-api-integration.test.ts +3 -1
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +90 -30
- package/src/tests/runner-repo-autostash.test.ts +117 -0
- package/src/tests/runner-requester-profile.test.ts +25 -0
- package/src/tests/runner-skills-refresh.test.ts +1 -1
- package/src/tests/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +3 -0
- package/src/tests/ui-logs-parser.test.ts +271 -0
- package/src/tests/user-token-rest-auth.test.ts +129 -0
- package/src/tests/workflow-async-v2.test.ts +23 -0
- package/src/tests/x-composio.test.ts +122 -0
- package/src/tools/create-metric.ts +191 -0
- package/src/tools/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +120 -0
- package/src/utils/request-auth-context.ts +28 -0
- package/src/utils/skills-refresh.ts +2 -2
- package/src/workflows/engine.ts +24 -2
- package/src/workflows/executors/agent-task.ts +2 -0
- package/src/x/composio.ts +295 -0
- package/templates/skills/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- package/templates/skills/attio-interaction/content.md +272 -0
package/src/http/poll.ts
CHANGED
|
@@ -86,6 +86,10 @@ const pollTriggers = route({
|
|
|
86
86
|
const CHANNEL_ACTIVITY_INTERVAL_MS = 60_000; // Check at most once per 60s
|
|
87
87
|
let lastChannelActivityCheckAt = 0;
|
|
88
88
|
|
|
89
|
+
function getRequesterNotes(notes: string | undefined): string | undefined {
|
|
90
|
+
return typeof notes === "string" && notes.trim().length > 0 ? notes : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
89
93
|
// ─── Cursor Commit Endpoint ─────────────────────────────────────────────────
|
|
90
94
|
|
|
91
95
|
const commitCursorsRoute = route({
|
|
@@ -256,6 +260,7 @@ export async function handlePoll(
|
|
|
256
260
|
const requestedByUser = pendingTask.requestedByUserId
|
|
257
261
|
? getUserById(pendingTask.requestedByUserId)
|
|
258
262
|
: undefined;
|
|
263
|
+
const requestedByNotes = getRequesterNotes(requestedByUser?.notes);
|
|
259
264
|
|
|
260
265
|
return {
|
|
261
266
|
trigger: {
|
|
@@ -263,7 +268,12 @@ export async function handlePoll(
|
|
|
263
268
|
taskId: pendingTask.id,
|
|
264
269
|
task: { ...pendingTask, status: "in_progress" },
|
|
265
270
|
...(requestedByUser && {
|
|
266
|
-
requestedBy: {
|
|
271
|
+
requestedBy: {
|
|
272
|
+
name: requestedByUser.name,
|
|
273
|
+
email: requestedByUser.email,
|
|
274
|
+
role: requestedByUser.role,
|
|
275
|
+
notes: requestedByNotes,
|
|
276
|
+
},
|
|
267
277
|
}),
|
|
268
278
|
},
|
|
269
279
|
};
|
package/src/http/tasks.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import { ensure } from "@desplega.ai/business-use";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import {
|
|
5
|
+
backfillSupersedeTaskResumeTaskId,
|
|
5
6
|
cancelTask,
|
|
6
7
|
completeTask,
|
|
7
8
|
failTask,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
ProviderNameSchema,
|
|
35
36
|
ResumeReasonSchema,
|
|
36
37
|
} from "../types";
|
|
38
|
+
import { getRequestAuth } from "../utils/request-auth-context";
|
|
37
39
|
import { route } from "./route-def";
|
|
38
40
|
import { json, jsonError } from "./utils";
|
|
39
41
|
|
|
@@ -354,7 +356,9 @@ export async function handleTasks(
|
|
|
354
356
|
// Tolerant `requestedByUserId`: prevent the deleted-user race from
|
|
355
357
|
// becoming a 500 — if the referenced user doesn't exist, log and drop
|
|
356
358
|
// the field rather than letting the FK fail at INSERT.
|
|
357
|
-
|
|
359
|
+
const auth = getRequestAuth(req);
|
|
360
|
+
let requestedByUserId =
|
|
361
|
+
auth?.kind === "user" ? auth.userId : parsed.body.requestedByUserId || undefined;
|
|
358
362
|
if (requestedByUserId && !getUserById(requestedByUserId)) {
|
|
359
363
|
console.warn(
|
|
360
364
|
`[tasks] requestedByUserId ${requestedByUserId} does not exist — coercing to NULL`,
|
|
@@ -902,6 +906,7 @@ export async function handleTasks(
|
|
|
902
906
|
}
|
|
903
907
|
|
|
904
908
|
const resumeTaskId = followUp.task.id;
|
|
909
|
+
backfillSupersedeTaskResumeTaskId(parsed.params.id, resumeTaskId);
|
|
905
910
|
|
|
906
911
|
ensure({
|
|
907
912
|
id: "task.superseded",
|
package/src/http/workflows.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
WorkflowPatchSchema,
|
|
22
22
|
WorkflowRunStatusSchema,
|
|
23
23
|
} from "../types";
|
|
24
|
+
import { getRequestAuth } from "../utils/request-auth-context";
|
|
24
25
|
import { getExecutorRegistry, startWorkflowExecution } from "../workflows";
|
|
25
26
|
import { applyDefinitionPatch, generateEdges, validateDefinition } from "../workflows/definition";
|
|
26
27
|
import { TriggerSchemaError } from "../workflows/engine";
|
|
@@ -645,10 +646,13 @@ export async function handleWorkflows(
|
|
|
645
646
|
return true;
|
|
646
647
|
}
|
|
647
648
|
const body = await parseBody<Record<string, unknown>>(req);
|
|
649
|
+
const auth = getRequestAuth(req);
|
|
648
650
|
|
|
649
651
|
let runId: string;
|
|
650
652
|
try {
|
|
651
|
-
runId = await startWorkflowExecution(workflow, body, getExecutorRegistry()
|
|
653
|
+
runId = await startWorkflowExecution(workflow, body, getExecutorRegistry(), {
|
|
654
|
+
requestedByUserId: auth?.kind === "user" ? auth.userId : undefined,
|
|
655
|
+
});
|
|
652
656
|
} catch (err) {
|
|
653
657
|
if (err instanceof TriggerSchemaError) {
|
|
654
658
|
triggerSchemaErrorResponse(res, err.message, err.validationErrors);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createMetricVersion, getMetric, getMetricVersions } from "../be/db";
|
|
2
|
+
import type { MetricSnapshot, MetricVersion } from "../types";
|
|
3
|
+
|
|
4
|
+
export function snapshotMetric(metricId: string, changedByAgentId?: string): MetricVersion {
|
|
5
|
+
const metric = getMetric(metricId);
|
|
6
|
+
if (!metric) {
|
|
7
|
+
throw new Error(`Metric ${metricId} not found — cannot create snapshot`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const existingVersions = getMetricVersions(metricId);
|
|
11
|
+
const maxVersion = existingVersions.length > 0 ? existingVersions[0]!.version : 0;
|
|
12
|
+
const nextVersion = maxVersion + 1;
|
|
13
|
+
|
|
14
|
+
const snapshot: MetricSnapshot = {
|
|
15
|
+
title: metric.title,
|
|
16
|
+
description: metric.description,
|
|
17
|
+
definition: metric.definition,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return createMetricVersion({
|
|
21
|
+
metricId,
|
|
22
|
+
version: nextVersion,
|
|
23
|
+
snapshot,
|
|
24
|
+
changedByAgentId,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -72,6 +72,7 @@ export type BasePromptArgs = {
|
|
|
72
72
|
claudeMd?: string | null;
|
|
73
73
|
clonePath: string;
|
|
74
74
|
warning?: string | null;
|
|
75
|
+
autoStashes?: { ref: string; message: string }[];
|
|
75
76
|
guidelines?: {
|
|
76
77
|
prChecks: string[];
|
|
77
78
|
mergeChecks: string[];
|
|
@@ -197,6 +198,13 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
|
|
|
197
198
|
} else if (!args.repoContext.warning) {
|
|
198
199
|
prompt += `Repository is cloned at \`${args.repoContext.clonePath}\` but has no CLAUDE.md file.\n`;
|
|
199
200
|
}
|
|
201
|
+
|
|
202
|
+
if (args.repoContext.autoStashes && args.repoContext.autoStashes.length > 0) {
|
|
203
|
+
const stashes = args.repoContext.autoStashes
|
|
204
|
+
.map((stash) => `- ${stash.ref}: ${stash.message}`)
|
|
205
|
+
.join("\n");
|
|
206
|
+
prompt += `\nPending auto-stashed work exists in this repo:\n${stashes}\nRestore if relevant with \`git stash apply <ref>\` or \`git stash pop <ref>\`.\n`;
|
|
207
|
+
}
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
// Inject repo guidelines
|
|
@@ -565,6 +565,29 @@ When working in a repository, your system prompt may include a **Repository Guid
|
|
|
565
565
|
category: "system",
|
|
566
566
|
});
|
|
567
567
|
|
|
568
|
+
// ============================================================================
|
|
569
|
+
// Per-task prompt templates (category: "task_lifecycle")
|
|
570
|
+
// ============================================================================
|
|
571
|
+
|
|
572
|
+
registerTemplate({
|
|
573
|
+
eventType: "task.requester.profile",
|
|
574
|
+
header: "",
|
|
575
|
+
defaultBody: `
|
|
576
|
+
## Requester Profile
|
|
577
|
+
This task was requested by {{requester_name}}{{requester_role_suffix}}.{{requester_notes_section}}
|
|
578
|
+
Honor this requester profile in tone, depth, and format where it doesn't conflict with correctness or your operating rules.
|
|
579
|
+
`,
|
|
580
|
+
variables: [
|
|
581
|
+
{ name: "requester_name", description: "The requesting user's display name" },
|
|
582
|
+
{ name: "requester_role_suffix", description: "Formatted role suffix, including parentheses" },
|
|
583
|
+
{
|
|
584
|
+
name: "requester_notes_section",
|
|
585
|
+
description: "Formatted notes section sourced from users.notes, or empty string",
|
|
586
|
+
},
|
|
587
|
+
],
|
|
588
|
+
category: "task_lifecycle",
|
|
589
|
+
});
|
|
590
|
+
|
|
568
591
|
// ============================================================================
|
|
569
592
|
// Composite session templates (category: "session")
|
|
570
593
|
// ============================================================================
|
|
@@ -219,6 +219,7 @@ export class OpencodeSession implements ProviderSession {
|
|
|
219
219
|
private completionPromise: Promise<ProviderResult>;
|
|
220
220
|
private server: { url: string; close(): void };
|
|
221
221
|
private aborted = false;
|
|
222
|
+
private completed = false;
|
|
222
223
|
|
|
223
224
|
// Running cost accumulators
|
|
224
225
|
private totalCostUsd = 0;
|
|
@@ -273,6 +274,10 @@ export class OpencodeSession implements ProviderSession {
|
|
|
273
274
|
return this._sessionId;
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
get isFinished(): boolean {
|
|
278
|
+
return this.completed;
|
|
279
|
+
}
|
|
280
|
+
|
|
276
281
|
/** Emit the synthetic session_init event. Called by the adapter immediately
|
|
277
282
|
* after construction; buffers if no listener is attached yet. */
|
|
278
283
|
emitSessionInit(provider: "opencode"): void {
|
|
@@ -358,7 +363,7 @@ export class OpencodeSession implements ProviderSession {
|
|
|
358
363
|
|
|
359
364
|
/** Process a single opencode SSE event */
|
|
360
365
|
handleOpencodeEvent(ev: OpencodeEvent): void {
|
|
361
|
-
if (this.aborted) return;
|
|
366
|
+
if (this.aborted || this.completed) return;
|
|
362
367
|
|
|
363
368
|
// Always emit the raw event as a scrubbed raw_log
|
|
364
369
|
const rawContent = scrubSecrets(JSON.stringify(ev));
|
|
@@ -471,7 +476,7 @@ export class OpencodeSession implements ProviderSession {
|
|
|
471
476
|
for (const l of this.listeners) l(resultEvent);
|
|
472
477
|
const raw = scrubSecrets(JSON.stringify(resultEvent));
|
|
473
478
|
this.emitDirect({ type: "raw_log", content: raw });
|
|
474
|
-
this.
|
|
479
|
+
void this.finish({
|
|
475
480
|
exitCode: 0,
|
|
476
481
|
sessionId: this._sessionId,
|
|
477
482
|
cost,
|
|
@@ -515,7 +520,7 @@ export class OpencodeSession implements ProviderSession {
|
|
|
515
520
|
const raw = scrubSecrets(JSON.stringify(errorEvent));
|
|
516
521
|
this.emitDirect({ type: "raw_log", content: raw });
|
|
517
522
|
const cost = this.buildCostData(true);
|
|
518
|
-
this.
|
|
523
|
+
void this.finish({
|
|
519
524
|
exitCode: 1,
|
|
520
525
|
sessionId: this._sessionId,
|
|
521
526
|
cost,
|
|
@@ -546,12 +551,22 @@ export class OpencodeSession implements ProviderSession {
|
|
|
546
551
|
return this.completionPromise;
|
|
547
552
|
}
|
|
548
553
|
|
|
554
|
+
private async finish(result: ProviderResult): Promise<void> {
|
|
555
|
+
if (this.completed) return;
|
|
556
|
+
this.completed = true;
|
|
557
|
+
try {
|
|
558
|
+
this.server.close();
|
|
559
|
+
} catch {
|
|
560
|
+
// best-effort
|
|
561
|
+
}
|
|
562
|
+
await this.cleanupFiles();
|
|
563
|
+
this.completionResolve(result);
|
|
564
|
+
}
|
|
565
|
+
|
|
549
566
|
async abort(): Promise<void> {
|
|
550
567
|
if (this.aborted) return;
|
|
551
568
|
this.aborted = true;
|
|
552
|
-
this.
|
|
553
|
-
await this.cleanupFiles();
|
|
554
|
-
this.completionResolve({
|
|
569
|
+
await this.finish({
|
|
555
570
|
exitCode: 1,
|
|
556
571
|
sessionId: this._sessionId,
|
|
557
572
|
isError: true,
|
|
@@ -760,6 +775,7 @@ export class OpencodeAdapter implements ProviderAdapter {
|
|
|
760
775
|
.then(async ({ stream }) => {
|
|
761
776
|
for await (const event of stream) {
|
|
762
777
|
session.handleOpencodeEvent(event as OpencodeEvent);
|
|
778
|
+
if (session.isFinished) break;
|
|
763
779
|
}
|
|
764
780
|
// Stream ended without session.idle — treat as completion
|
|
765
781
|
})
|
package/src/server.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { registerCancelTaskTool } from "./tools/cancel-task";
|
|
|
6
6
|
import { registerContextDiffTool } from "./tools/context-diff";
|
|
7
7
|
import { registerContextHistoryTool } from "./tools/context-history";
|
|
8
8
|
import { registerCreateChannelTool } from "./tools/create-channel";
|
|
9
|
+
import { registerCreateMetricTool } from "./tools/create-metric";
|
|
9
10
|
import { registerCreatePageTool } from "./tools/create-page";
|
|
10
11
|
import { registerDbQueryTool } from "./tools/db-query";
|
|
11
12
|
import { registerDeleteChannelTool } from "./tools/delete-channel";
|
|
@@ -109,6 +110,7 @@ import {
|
|
|
109
110
|
registerListConfigTool,
|
|
110
111
|
registerSetConfigTool,
|
|
111
112
|
} from "./tools/swarm-config";
|
|
113
|
+
import { registerSwarmXTool } from "./tools/swarm-x";
|
|
112
114
|
// Task pool capability
|
|
113
115
|
import { registerTaskActionTool } from "./tools/task-action";
|
|
114
116
|
// Tracker capability
|
|
@@ -146,7 +148,7 @@ import {
|
|
|
146
148
|
// Capability-based feature flags
|
|
147
149
|
// Default: all capabilities enabled
|
|
148
150
|
const DEFAULT_CAPABILITIES =
|
|
149
|
-
"core,task-pool,profiles,services,scheduling,memory,workflows,pages,kv";
|
|
151
|
+
"core,task-pool,profiles,services,scheduling,memory,workflows,pages,metrics,kv";
|
|
150
152
|
const CAPABILITIES = new Set(
|
|
151
153
|
(process.env.CAPABILITIES || DEFAULT_CAPABILITIES).split(",").map((s) => s.trim()),
|
|
152
154
|
);
|
|
@@ -226,6 +228,9 @@ export function createServer() {
|
|
|
226
228
|
registerScriptDeleteTool(server);
|
|
227
229
|
registerScriptQueryTypesTool(server);
|
|
228
230
|
|
|
231
|
+
// External command routes - mirrors the `agent-swarm x ...` CLI surface.
|
|
232
|
+
registerSwarmXTool(server);
|
|
233
|
+
|
|
229
234
|
// Slack integration tools (always registered, will no-op if Slack not configured)
|
|
230
235
|
registerSlackReplyTool(server);
|
|
231
236
|
registerSlackReadTool(server);
|
|
@@ -338,6 +343,10 @@ export function createServer() {
|
|
|
338
343
|
registerCreatePageTool(server);
|
|
339
344
|
}
|
|
340
345
|
|
|
346
|
+
if (hasCapability("metrics")) {
|
|
347
|
+
registerCreateMetricTool(server);
|
|
348
|
+
}
|
|
349
|
+
|
|
341
350
|
// KV capability — namespaced Redis-like key/value (see src/be/migrations/061_kv_store.sql).
|
|
342
351
|
// Enabled by default; opt out via `CAPABILITIES=...` without `kv`.
|
|
343
352
|
if (hasCapability("kv")) {
|
|
@@ -22,6 +22,20 @@ export const WORKER_LIVENESS_WINDOW_SECONDS = Number(
|
|
|
22
22
|
process.env.WORKER_LIVENESS_WINDOW_SECONDS || "30",
|
|
23
23
|
);
|
|
24
24
|
|
|
25
|
+
export const RESUME_GENERATION_TAG_PREFIX = "resume-generation:";
|
|
26
|
+
|
|
27
|
+
export function getResumeGeneration(task: Pick<AgentTask, "tags">): number {
|
|
28
|
+
const tag = task.tags.find((value) => value.startsWith(RESUME_GENERATION_TAG_PREFIX));
|
|
29
|
+
if (!tag) return 0;
|
|
30
|
+
|
|
31
|
+
const parsed = Number(tag.slice(RESUME_GENERATION_TAG_PREFIX.length));
|
|
32
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getNextResumeGeneration(parent: Pick<AgentTask, "tags">): number {
|
|
36
|
+
return getResumeGeneration(parent) + 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
function attachmentPointer(a: TaskAttachment): string {
|
|
26
40
|
switch (a.kind) {
|
|
27
41
|
case "url":
|
|
@@ -205,7 +219,11 @@ export function createResumeFollowUp(args: {
|
|
|
205
219
|
].join("\n");
|
|
206
220
|
|
|
207
221
|
const priority = Math.min(100, (parent.priority ?? 50) + 10);
|
|
208
|
-
const tags = [
|
|
222
|
+
const tags = [
|
|
223
|
+
"auto-resume",
|
|
224
|
+
`reason:${args.reason}`,
|
|
225
|
+
`${RESUME_GENERATION_TAG_PREFIX}${getNextResumeGeneration(parent)}`,
|
|
226
|
+
];
|
|
209
227
|
|
|
210
228
|
// Identity-shaped fields (dir, VCS provider/repo/number/url/etc.,
|
|
211
229
|
// outputSchema, slack channel/thread/user, agentmail, mention, contextKey,
|
|
@@ -272,6 +272,41 @@ describe("getBasePrompt — repoContext", () => {
|
|
|
272
272
|
expect(result).toContain("Repository Guidelines (MANDATORY)");
|
|
273
273
|
expect(result).toContain("Auto-merge: Allowed");
|
|
274
274
|
});
|
|
275
|
+
|
|
276
|
+
test("surfaces swarm-autostash entries when present", async () => {
|
|
277
|
+
const result = await getBasePrompt({
|
|
278
|
+
...minimalArgs,
|
|
279
|
+
repoContext: {
|
|
280
|
+
claudeMd: "Rules",
|
|
281
|
+
clonePath: "/workspace/my-repo",
|
|
282
|
+
autoStashes: [
|
|
283
|
+
{
|
|
284
|
+
ref: "stash@{0}",
|
|
285
|
+
message: "On main: swarm-autostash main 2026-06-01T13:00:00.000Z",
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(result).toContain("Pending auto-stashed work exists in this repo");
|
|
292
|
+
expect(result).toContain("stash@{0}: On main: swarm-autostash main");
|
|
293
|
+
expect(result).toContain("git stash apply <ref>");
|
|
294
|
+
expect(result).toContain("git stash pop <ref>");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("does not mention auto-stashed work when no swarm-autostash entries exist", async () => {
|
|
298
|
+
const result = await getBasePrompt({
|
|
299
|
+
...minimalArgs,
|
|
300
|
+
repoContext: {
|
|
301
|
+
claudeMd: "Rules",
|
|
302
|
+
clonePath: "/workspace/my-repo",
|
|
303
|
+
autoStashes: [],
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(result).not.toContain("Pending auto-stashed work exists in this repo");
|
|
308
|
+
expect(result).not.toContain("git stash apply <ref>");
|
|
309
|
+
});
|
|
275
310
|
});
|
|
276
311
|
|
|
277
312
|
// ---------------------------------------------------------------------------
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
createAgent,
|
|
12
12
|
createSessionCost,
|
|
13
13
|
createTaskExtended,
|
|
14
|
+
createUser,
|
|
14
15
|
getAgentById,
|
|
15
16
|
getDb,
|
|
16
17
|
incrementEmptyPollCount,
|
|
@@ -129,6 +130,31 @@ describe("Phase 3 — /api/poll budget admission gate", () => {
|
|
|
129
130
|
expect((body.trigger as { taskId: string }).taskId).toBe(task.id);
|
|
130
131
|
});
|
|
131
132
|
|
|
133
|
+
test("pending task trigger includes requester role and notes", async () => {
|
|
134
|
+
const worker = createAgent({ name: "w-requester", isLead: false, status: "idle", maxTasks: 1 });
|
|
135
|
+
const requester = createUser({
|
|
136
|
+
name: "Requester One",
|
|
137
|
+
email: "requester@example.com",
|
|
138
|
+
role: "engineering manager",
|
|
139
|
+
notes: "Include implementation detail and test coverage.",
|
|
140
|
+
});
|
|
141
|
+
createTaskExtended("profile-aware task", {
|
|
142
|
+
agentId: worker.id,
|
|
143
|
+
requestedByUserId: requester.id,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const { status, body } = await callPoll(worker.id);
|
|
147
|
+
expect(status).toBe(200);
|
|
148
|
+
if ("error" in body) throw new Error("unexpected error response");
|
|
149
|
+
expect(body.trigger?.type).toBe("task_assigned");
|
|
150
|
+
expect(body.trigger?.requestedBy).toEqual({
|
|
151
|
+
name: "Requester One",
|
|
152
|
+
email: "requester@example.com",
|
|
153
|
+
role: "engineering manager",
|
|
154
|
+
notes: "Include implementation detail and test coverage.",
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
132
158
|
test("no budgets configured + no work → trigger=null", async () => {
|
|
133
159
|
const worker = createAgent({ name: "w-empty", isLead: false, status: "idle", maxTasks: 1 });
|
|
134
160
|
const { status, body } = await callPoll(worker.id);
|
|
@@ -135,8 +135,15 @@ describe("handleCore auth middleware (no API_KEY configured)", () => {
|
|
|
135
135
|
server.close();
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
-
test("authed routes
|
|
138
|
+
test("authed routes fail closed without Bearer when API_KEY is empty", async () => {
|
|
139
139
|
const res = await fetch(`http://localhost:${port}/api/mcp-oauth/some-id/status`);
|
|
140
|
+
expect(res.status).toBe(401);
|
|
141
|
+
const body = await res.json();
|
|
142
|
+
expect(body.error).toBe("Unauthorized");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("public routes still pass when API_KEY is empty", async () => {
|
|
146
|
+
const res = await fetch(`http://localhost:${port}/api/mcp-oauth/callback`);
|
|
140
147
|
expect(res.status).not.toBe(401);
|
|
141
148
|
});
|
|
142
149
|
});
|
|
@@ -5,6 +5,7 @@ import type { Subprocess } from "bun";
|
|
|
5
5
|
const TEST_PORT = 13033;
|
|
6
6
|
const TEST_DB_PATH = `/tmp/test-events-http-${Date.now()}.sqlite`;
|
|
7
7
|
const BASE = `http://localhost:${TEST_PORT}`;
|
|
8
|
+
const TEST_API_KEY = "test-events-http-key";
|
|
8
9
|
|
|
9
10
|
let serverProc: Subprocess;
|
|
10
11
|
|
|
@@ -15,7 +16,10 @@ async function api(
|
|
|
15
16
|
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
16
17
|
const res = await fetch(`${BASE}${path}`, {
|
|
17
18
|
method,
|
|
18
|
-
headers: {
|
|
19
|
+
headers: {
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
22
|
+
},
|
|
19
23
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
20
24
|
});
|
|
21
25
|
const text = await res.text();
|
|
@@ -54,7 +58,7 @@ beforeAll(async () => {
|
|
|
54
58
|
...process.env,
|
|
55
59
|
PORT: String(TEST_PORT),
|
|
56
60
|
DATABASE_PATH: TEST_DB_PATH,
|
|
57
|
-
API_KEY:
|
|
61
|
+
API_KEY: TEST_API_KEY,
|
|
58
62
|
CAPABILITIES: "core",
|
|
59
63
|
SLACK_BOT_TOKEN: "",
|
|
60
64
|
GITHUB_WEBHOOK_SECRET: "",
|