@desplega.ai/agent-swarm 1.71.2 → 1.72.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.
Files changed (62) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +994 -62
  3. package/package.json +2 -1
  4. package/src/be/budget-admission.ts +121 -0
  5. package/src/be/budget-refusal-notify.ts +145 -0
  6. package/src/be/db.ts +488 -5
  7. package/src/be/migrations/044_provider_meta.sql +2 -0
  8. package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
  9. package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
  10. package/src/cli.tsx +22 -1
  11. package/src/commands/claude-managed-setup.ts +687 -0
  12. package/src/commands/codex-login.ts +1 -1
  13. package/src/commands/runner.ts +175 -28
  14. package/src/commands/templates.ts +10 -6
  15. package/src/http/budgets.ts +219 -0
  16. package/src/http/index.ts +6 -0
  17. package/src/http/integrations.ts +134 -0
  18. package/src/http/poll.ts +161 -3
  19. package/src/http/pricing.ts +245 -0
  20. package/src/http/session-data.ts +54 -6
  21. package/src/http/tasks.ts +23 -2
  22. package/src/prompts/base-prompt.ts +103 -73
  23. package/src/prompts/session-templates.ts +43 -0
  24. package/src/providers/claude-adapter.ts +3 -1
  25. package/src/providers/claude-managed-adapter.ts +871 -0
  26. package/src/providers/claude-managed-models.ts +117 -0
  27. package/src/providers/claude-managed-swarm-events.ts +77 -0
  28. package/src/providers/codex-adapter.ts +3 -1
  29. package/src/providers/codex-skill-resolver.ts +10 -0
  30. package/src/providers/codex-swarm-events.ts +20 -161
  31. package/src/providers/devin-adapter.ts +894 -0
  32. package/src/providers/devin-api.ts +207 -0
  33. package/src/providers/devin-playbooks.ts +91 -0
  34. package/src/providers/devin-skill-resolver.ts +113 -0
  35. package/src/providers/index.ts +10 -1
  36. package/src/providers/pi-mono-adapter.ts +3 -1
  37. package/src/providers/swarm-events-shared.ts +262 -0
  38. package/src/providers/types.ts +26 -1
  39. package/src/tests/base-prompt.test.ts +199 -0
  40. package/src/tests/budget-admission.test.ts +339 -0
  41. package/src/tests/budget-claim-gate.test.ts +288 -0
  42. package/src/tests/budget-refusal-notification.test.ts +324 -0
  43. package/src/tests/budgets-routes.test.ts +331 -0
  44. package/src/tests/claude-managed-adapter.test.ts +1301 -0
  45. package/src/tests/claude-managed-setup.test.ts +325 -0
  46. package/src/tests/devin-adapter.test.ts +677 -0
  47. package/src/tests/devin-api.test.ts +339 -0
  48. package/src/tests/integrations-http.test.ts +211 -0
  49. package/src/tests/migration-046-budgets.test.ts +327 -0
  50. package/src/tests/pricing-routes.test.ts +315 -0
  51. package/src/tests/prompt-template-remaining.test.ts +4 -0
  52. package/src/tests/prompt-template-session.test.ts +2 -2
  53. package/src/tests/provider-adapter.test.ts +1 -1
  54. package/src/tests/runner-budget-refused.test.ts +271 -0
  55. package/src/tests/session-costs-codex-recompute.test.ts +386 -0
  56. package/src/tools/poll-task.ts +13 -2
  57. package/src/tools/task-action.ts +92 -2
  58. package/src/tools/templates.ts +29 -0
  59. package/src/types.ts +116 -0
  60. package/src/utils/budget-backoff.ts +34 -0
  61. package/src/utils/credentials.ts +4 -0
  62. package/src/utils/provider-metadata.ts +9 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Shared adapter-side swarm-event handler factory.
3
+ *
4
+ * Phase 5 extraction (see thoughts/.../2026-04-28-claude-managed-agents-provider.md
5
+ * § Phase 5). Originally lived inline in `codex-swarm-events.ts`; lifted out
6
+ * here so the `claude-managed` adapter (and any future adapter that wants the
7
+ * same throttle/poll/heartbeat scaffolding) can reuse it.
8
+ *
9
+ * ## What this owns
10
+ *
11
+ * - `apiHeaders`, `fireAndForget`
12
+ * - Throttle constants and the `shouldRun` gate
13
+ * - `checkCancelled`, `checkLoop`, `heartbeat`, `activity`
14
+ * - `progressContextUsage`, `progressCompaction`, `progressCompletion`
15
+ *
16
+ * ## What this does NOT own
17
+ *
18
+ * Provider-specific dispatch lives in each adapter's `*-swarm-events.ts`. For
19
+ * example codex's per-turn cost-data shape, or claude-managed's interrupt+
20
+ * archive cancel callback. The shared file exposes the throttled primitives
21
+ * and a generic event-dispatch shell; the per-provider wrapper supplies any
22
+ * `onCancel` / extension points it needs.
23
+ *
24
+ * ## Two-layer cancellation (unchanged from codex)
25
+ *
26
+ * Layer 1 — runner-side polling: `src/commands/runner.ts` polls
27
+ * `GET /cancelled-tasks` on a timer and calls `session.abort()`. All providers
28
+ * inherit this for free.
29
+ *
30
+ * Layer 2 — adapter-side (this file): on every `tool_start` we (throttled)
31
+ * check the same endpoint and abort the running turn via the shared
32
+ * `AbortController`. This *accelerates* cancellation latency.
33
+ *
34
+ * ## Hard contract
35
+ *
36
+ * - The handler is synchronous from the caller's perspective.
37
+ * - Every fetch is fire-and-forget with `.catch(() => {})` so a single bad
38
+ * request never breaks the session.
39
+ * - The handler never throws — `try/catch` around the dispatch swallows
40
+ * everything for safety.
41
+ */
42
+
43
+ import { checkToolLoop } from "../hooks/tool-loop-detection";
44
+ import type { ProviderEvent } from "./types";
45
+
46
+ /** Throttle windows (ms) keyed by action name. Exported for unit assertions. */
47
+ export const CANCELLATION_THROTTLE_MS = 500;
48
+ export const HEARTBEAT_THROTTLE_MS = 5_000;
49
+ export const ACTIVITY_THROTTLE_MS = 5_000;
50
+ export const CONTEXT_THROTTLE_MS = 30_000;
51
+
52
+ export interface SwarmEventHandlerOpts {
53
+ apiUrl: string;
54
+ apiKey: string;
55
+ agentId: string;
56
+ /** Task currently being worked on. When null, all task-scoped hooks are no-ops. */
57
+ taskId: string | null;
58
+ /** Mutable reference to the session's per-turn AbortController. */
59
+ abortRef: { current: AbortController | null };
60
+ /**
61
+ * Optional callback invoked when a cancellation is detected (in addition to
62
+ * the abort-controller fire). Adapters that need provider-specific cancel
63
+ * actions (e.g. claude-managed sending `user.interrupt` + archiving) wire
64
+ * it here. Errors are swallowed.
65
+ */
66
+ onCancel?: () => void | Promise<void>;
67
+ /**
68
+ * Prefix used in the synthetic `sessionId` body field on context POSTs when
69
+ * `session_init` hasn't fired yet. Codex preserves its historical
70
+ * `codex-${taskId}` shape; new providers can pick their own.
71
+ */
72
+ sessionIdFallbackPrefix?: string;
73
+ }
74
+
75
+ export function apiHeaders(opts: SwarmEventHandlerOpts): Record<string, string> {
76
+ return {
77
+ "Content-Type": "application/json",
78
+ Authorization: `Bearer ${opts.apiKey}`,
79
+ "X-Agent-ID": opts.agentId,
80
+ };
81
+ }
82
+
83
+ export function fireAndForget(url: string, init: RequestInit): void {
84
+ void fetch(url, init).catch(() => {});
85
+ }
86
+
87
+ /**
88
+ * Build the handler. The returned function reacts to normalized events.
89
+ *
90
+ * Mirrors the codex-swarm-events behavior exactly — providers that want
91
+ * additional event-type handling can compose this with their own dispatch
92
+ * (e.g. by wrapping the returned handler).
93
+ */
94
+ export function createSwarmEventHandler(
95
+ opts: SwarmEventHandlerOpts,
96
+ ): (event: ProviderEvent) => void {
97
+ const lastCall: Record<string, number> = {};
98
+ let sessionId: string | undefined;
99
+
100
+ const shouldRun = (key: string, throttleMs: number): boolean => {
101
+ const now = Date.now();
102
+ if (now - (lastCall[key] ?? 0) < throttleMs) return false;
103
+ lastCall[key] = now;
104
+ return true;
105
+ };
106
+
107
+ const checkCancelled = (): void => {
108
+ const taskId = opts.taskId;
109
+ if (!taskId) return;
110
+ void (async () => {
111
+ try {
112
+ const res = await fetch(
113
+ `${opts.apiUrl}/cancelled-tasks?taskId=${encodeURIComponent(taskId)}`,
114
+ { headers: apiHeaders(opts) },
115
+ );
116
+ if (!res.ok) return;
117
+ const data = (await res.json()) as {
118
+ cancelled?: Array<{ id: string; failureReason?: string }>;
119
+ };
120
+ const isCancelled = data.cancelled?.some((t) => t.id === taskId);
121
+ if (isCancelled) {
122
+ opts.abortRef.current?.abort();
123
+ if (opts.onCancel) {
124
+ try {
125
+ await opts.onCancel();
126
+ } catch {
127
+ // Swallow — best-effort.
128
+ }
129
+ }
130
+ }
131
+ } catch {
132
+ // Swallow — fire-and-forget.
133
+ }
134
+ })();
135
+ };
136
+
137
+ const checkLoop = (toolName: string, args: unknown): void => {
138
+ const taskId = opts.taskId;
139
+ if (!taskId) return;
140
+ const argRecord = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
141
+ void checkToolLoop(taskId, toolName, argRecord)
142
+ .then((result) => {
143
+ if (result.blocked) {
144
+ opts.abortRef.current?.abort();
145
+ }
146
+ })
147
+ .catch(() => {});
148
+ };
149
+
150
+ const heartbeat = (): void => {
151
+ if (opts.taskId && shouldRun("heartbeat", HEARTBEAT_THROTTLE_MS)) {
152
+ fireAndForget(
153
+ `${opts.apiUrl}/api/active-sessions/heartbeat/${encodeURIComponent(opts.taskId)}`,
154
+ { method: "PUT", headers: apiHeaders(opts) },
155
+ );
156
+ }
157
+ };
158
+
159
+ const activity = (): void => {
160
+ if (shouldRun("activity", ACTIVITY_THROTTLE_MS)) {
161
+ fireAndForget(`${opts.apiUrl}/api/agents/${encodeURIComponent(opts.agentId)}/activity`, {
162
+ method: "PUT",
163
+ headers: apiHeaders(opts),
164
+ });
165
+ }
166
+ };
167
+
168
+ const progressContextUsage = (event: {
169
+ contextUsedTokens: number;
170
+ contextTotalTokens: number;
171
+ contextPercent: number;
172
+ }): void => {
173
+ if (opts.taskId && shouldRun("context-progress", CONTEXT_THROTTLE_MS)) {
174
+ fireAndForget(`${opts.apiUrl}/api/tasks/${encodeURIComponent(opts.taskId)}/context`, {
175
+ method: "POST",
176
+ headers: apiHeaders(opts),
177
+ body: JSON.stringify({
178
+ eventType: "progress",
179
+ sessionId: sessionId ?? `${opts.sessionIdFallbackPrefix ?? "session"}-${opts.taskId}`,
180
+ contextUsedTokens: event.contextUsedTokens,
181
+ contextTotalTokens: event.contextTotalTokens,
182
+ contextPercent: event.contextPercent,
183
+ }),
184
+ });
185
+ }
186
+ };
187
+
188
+ const progressCompaction = (event: {
189
+ contextTotalTokens: number;
190
+ preCompactTokens?: number;
191
+ compactTrigger?: string;
192
+ }): void => {
193
+ if (opts.taskId) {
194
+ fireAndForget(`${opts.apiUrl}/api/tasks/${encodeURIComponent(opts.taskId)}/context`, {
195
+ method: "POST",
196
+ headers: apiHeaders(opts),
197
+ body: JSON.stringify({
198
+ eventType: "compaction",
199
+ sessionId: sessionId ?? `${opts.sessionIdFallbackPrefix ?? "session"}-${opts.taskId}`,
200
+ contextTotalTokens: event.contextTotalTokens,
201
+ preCompactTokens: event.preCompactTokens,
202
+ compactTrigger: event.compactTrigger,
203
+ }),
204
+ });
205
+ }
206
+ };
207
+
208
+ const progressCompletion = (): void => {
209
+ if (opts.taskId) {
210
+ fireAndForget(`${opts.apiUrl}/api/tasks/${encodeURIComponent(opts.taskId)}/context`, {
211
+ method: "POST",
212
+ headers: apiHeaders(opts),
213
+ body: JSON.stringify({
214
+ eventType: "completion",
215
+ sessionId: sessionId ?? `${opts.sessionIdFallbackPrefix ?? "session"}-${opts.taskId}`,
216
+ }),
217
+ });
218
+ }
219
+ };
220
+
221
+ return (event: ProviderEvent): void => {
222
+ try {
223
+ switch (event.type) {
224
+ case "session_init": {
225
+ sessionId = event.sessionId;
226
+ break;
227
+ }
228
+ case "tool_start": {
229
+ if (shouldRun("cancellation", CANCELLATION_THROTTLE_MS)) {
230
+ checkCancelled();
231
+ }
232
+ checkLoop(event.toolName, event.args);
233
+ heartbeat();
234
+ activity();
235
+ break;
236
+ }
237
+ case "context_usage": {
238
+ progressContextUsage({
239
+ contextUsedTokens: event.contextUsedTokens,
240
+ contextTotalTokens: event.contextTotalTokens,
241
+ contextPercent: event.contextPercent,
242
+ });
243
+ break;
244
+ }
245
+ case "compaction": {
246
+ progressCompaction({
247
+ contextTotalTokens: event.contextTotalTokens,
248
+ preCompactTokens: event.preCompactTokens,
249
+ compactTrigger: event.compactTrigger,
250
+ });
251
+ break;
252
+ }
253
+ case "result": {
254
+ progressCompletion();
255
+ break;
256
+ }
257
+ }
258
+ } catch {
259
+ // Never throw from the handler — the event loop is hot.
260
+ }
261
+ };
262
+ }
@@ -12,11 +12,25 @@ export interface CostData {
12
12
  numTurns: number;
13
13
  model: string;
14
14
  isError: boolean;
15
+ /**
16
+ * Phase 6: tells the API which recompute path to use on
17
+ * `POST /api/session-costs`. Codex triggers the pricing-table recompute
18
+ * (when DB pricing rows exist for all three token classes); Claude / pi
19
+ * always trust the harness-reported `totalCostUsd` as-is.
20
+ */
21
+ provider?: "claude" | "codex" | "pi";
15
22
  }
16
23
 
24
+ import type { ProviderName } from "../types";
25
+
17
26
  /** Normalized event emitted by any provider adapter. */
18
27
  export type ProviderEvent =
19
- | { type: "session_init"; sessionId: string }
28
+ | {
29
+ type: "session_init";
30
+ sessionId: string;
31
+ provider?: ProviderName;
32
+ providerMeta?: Record<string, unknown>;
33
+ }
20
34
  | { type: "message"; role: "assistant" | "user"; content: string }
21
35
  | { type: "tool_start"; toolCallId: string; toolName: string; args: unknown }
22
36
  | { type: "tool_end"; toolCallId: string; toolName: string; result: unknown }
@@ -24,6 +38,7 @@ export type ProviderEvent =
24
38
  | { type: "error"; message: string; category?: string }
25
39
  | { type: "raw_log"; content: string }
26
40
  | { type: "raw_stderr"; content: string }
41
+ | { type: "progress"; message: string }
27
42
  | { type: "custom"; name: string; data: unknown }
28
43
  | {
29
44
  type: "context_usage";
@@ -50,6 +65,7 @@ export interface ProviderSessionConfig {
50
65
  apiUrl: string;
51
66
  apiKey: string;
52
67
  cwd: string;
68
+ vcsRepo?: string;
53
69
  resumeSessionId?: string;
54
70
  iteration?: number;
55
71
  logFile: string;
@@ -79,9 +95,18 @@ export interface ProviderResult {
79
95
  failureReason?: string;
80
96
  }
81
97
 
98
+ /** Behavioral traits that govern prompt assembly and feature gating. */
99
+ export interface ProviderTraits {
100
+ /** Provider can call MCP tools (store-progress, task-action, skills, slack-reply, etc.) */
101
+ hasMcp: boolean;
102
+ /** Provider runs in the local Docker container with /workspace, identity files, agent-fs, PM2, etc. */
103
+ hasLocalEnvironment: boolean;
104
+ }
105
+
82
106
  /** Main contract for a harness provider adapter. */
83
107
  export interface ProviderAdapter {
84
108
  readonly name: string;
109
+ readonly traits: ProviderTraits;
85
110
  createSession(config: ProviderSessionConfig): Promise<ProviderSession>;
86
111
  canResume(sessionId: string): Promise<boolean>;
87
112
  formatCommand(commandName: string): string;
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { type BasePromptArgs, getBasePrompt } from "../prompts/base-prompt";
3
+ import type { ProviderTraits } from "../providers/types";
3
4
 
4
5
  /** Minimal valid args to reduce boilerplate */
5
6
  const minimalArgs: BasePromptArgs = {
@@ -334,3 +335,201 @@ describe("getBasePrompt — truncation", () => {
334
335
  expect(result).not.toContain("[...truncated");
335
336
  });
336
337
  });
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Remote provider (no MCP, no local environment) — trait-aware prompt assembly
341
+ // ---------------------------------------------------------------------------
342
+ const remoteTraits: ProviderTraits = { hasMcp: false, hasLocalEnvironment: false };
343
+ const remoteProviderArgs: BasePromptArgs = {
344
+ ...minimalArgs,
345
+ traits: remoteTraits,
346
+ };
347
+
348
+ describe("getBasePrompt — remote provider composite selection", () => {
349
+ test("uses remote worker composite (not generic worker)", async () => {
350
+ const result = await getBasePrompt(remoteProviderArgs);
351
+ // Remote worker template says "output is captured automatically"
352
+ expect(result).toContain("output is captured automatically");
353
+ // Should NOT contain generic worker tools
354
+ expect(result).not.toContain("store-progress");
355
+ expect(result).not.toContain("task-action");
356
+ expect(result).not.toContain("read-messages");
357
+ });
358
+
359
+ test("still includes role and agentId", async () => {
360
+ const result = await getBasePrompt(remoteProviderArgs);
361
+ expect(result).toContain("worker");
362
+ expect(result).toContain("agent-abc-123");
363
+ });
364
+ });
365
+
366
+ describe("getBasePrompt — remote provider excluded sections", () => {
367
+ test("excludes join-swarm / register instructions", async () => {
368
+ const result = await getBasePrompt(remoteProviderArgs);
369
+ expect(result).not.toContain("join-swarm");
370
+ });
371
+
372
+ test("excludes /workspace filesystem layout", async () => {
373
+ const result = await getBasePrompt(remoteProviderArgs);
374
+ expect(result).not.toContain("/workspace/personal");
375
+ expect(result).not.toContain("/workspace/shared");
376
+ });
377
+
378
+ test("excludes How You Are Built section", async () => {
379
+ const result = await getBasePrompt(remoteProviderArgs);
380
+ expect(result).not.toContain("How You Are Built");
381
+ expect(result).not.toContain("hooks fire during your session");
382
+ });
383
+
384
+ test("excludes context mode tools", async () => {
385
+ const result = await getBasePrompt(remoteProviderArgs);
386
+ expect(result).not.toContain("context-mode");
387
+ expect(result).not.toContain("batch_execute");
388
+ });
389
+
390
+ test("excludes system packages section", async () => {
391
+ const result = await getBasePrompt(remoteProviderArgs);
392
+ expect(result).not.toContain("sudo apt-get install");
393
+ expect(result).not.toContain("System packages available");
394
+ });
395
+
396
+ test("excludes VCS CLI tools table", async () => {
397
+ const result = await getBasePrompt(remoteProviderArgs);
398
+ expect(result).not.toContain("glab mr create");
399
+ expect(result).not.toContain("gh pr create");
400
+ });
401
+
402
+ test("excludes service registry / PM2", async () => {
403
+ const result = await getBasePrompt(remoteProviderArgs);
404
+ expect(result).not.toContain("Service Registry");
405
+ expect(result).not.toContain("pm2 start");
406
+ });
407
+
408
+ test("excludes code quality section", async () => {
409
+ const result = await getBasePrompt(remoteProviderArgs);
410
+ expect(result).not.toContain("Code Quality");
411
+ expect(result).not.toContain("--no-verify");
412
+ });
413
+
414
+ test("excludes capabilities listing", async () => {
415
+ const result = await getBasePrompt({
416
+ ...remoteProviderArgs,
417
+ capabilities: ["core", "task-pool"],
418
+ });
419
+ expect(result).not.toContain("Capabilities enabled");
420
+ });
421
+
422
+ test("skips Slack instructions even with slackContext", async () => {
423
+ const result = await getBasePrompt({
424
+ ...remoteProviderArgs,
425
+ slackContext: { channelId: "C123", threadTs: "123.456" },
426
+ });
427
+ expect(result).not.toContain("slack-reply");
428
+ expect(result).not.toContain("C123");
429
+ });
430
+
431
+ test("skips skills section even when provided", async () => {
432
+ const result = await getBasePrompt({
433
+ ...remoteProviderArgs,
434
+ skillsSummary: [{ name: "commit", description: "Create a commit" }],
435
+ });
436
+ expect(result).not.toContain("Installed Skills");
437
+ expect(result).not.toContain("/commit");
438
+ });
439
+
440
+ test("skips MCP servers section even when provided", async () => {
441
+ const result = await getBasePrompt({
442
+ ...remoteProviderArgs,
443
+ mcpServersSummary: "- my-server: http://localhost:3000",
444
+ });
445
+ expect(result).not.toContain("Installed MCP Servers");
446
+ expect(result).not.toContain("my-server");
447
+ });
448
+
449
+ test("skips CLAUDE.md and TOOLS.md truncatable sections", async () => {
450
+ const result = await getBasePrompt({
451
+ ...remoteProviderArgs,
452
+ claudeMd: "# Agent instructions here",
453
+ toolsMd: "# Tools here",
454
+ });
455
+ expect(result).not.toContain("Agent Instructions");
456
+ expect(result).not.toContain("Agent instructions here");
457
+ expect(result).not.toContain("Your Tools & Capabilities");
458
+ expect(result).not.toContain("Tools here");
459
+ });
460
+ });
461
+
462
+ describe("getBasePrompt — remote provider identity", () => {
463
+ test("uses simplified identity (no SOUL.md / IDENTITY.md)", async () => {
464
+ const result = await getBasePrompt({
465
+ ...remoteProviderArgs,
466
+ name: "remote-worker-1",
467
+ description: "A remote worker",
468
+ soulMd: "# SOUL.md content that should NOT appear",
469
+ identityMd: "# IDENTITY.md content that should NOT appear",
470
+ });
471
+ expect(result).toContain("**Name:** remote-worker-1");
472
+ expect(result).toContain("**Description:** A remote worker");
473
+ expect(result).toContain("Desplega platform");
474
+ // Identity files should NOT be injected
475
+ expect(result).not.toContain("SOUL.md content that should NOT appear");
476
+ expect(result).not.toContain("IDENTITY.md content that should NOT appear");
477
+ });
478
+
479
+ test("identity section present even without name", async () => {
480
+ const result = await getBasePrompt(remoteProviderArgs);
481
+ expect(result).toContain("Your Identity");
482
+ expect(result).toContain("Desplega platform");
483
+ });
484
+ });
485
+
486
+ describe("getBasePrompt — remote provider keeps repo context", () => {
487
+ test("skips CLAUDE.md content for remote providers", async () => {
488
+ const result = await getBasePrompt({
489
+ ...remoteProviderArgs,
490
+ repoContext: {
491
+ claudeMd: "Run `bun test` before pushing.",
492
+ clonePath: "/workspace/repos/my-repo",
493
+ },
494
+ });
495
+ expect(result).toContain("Repository Context");
496
+ // Remote providers don't get claudeMd injected
497
+ expect(result).not.toContain("Run `bun test` before pushing.");
498
+ expect(result).not.toContain("/workspace/repos/my-repo");
499
+ });
500
+
501
+ test("includes repo guidelines", async () => {
502
+ const result = await getBasePrompt({
503
+ ...remoteProviderArgs,
504
+ repoContext: {
505
+ clonePath: "/workspace/repos/my-repo",
506
+ guidelines: {
507
+ prChecks: ["bun run lint:fix", "bun test"],
508
+ mergeChecks: [],
509
+ allowMerge: false,
510
+ review: [],
511
+ },
512
+ },
513
+ });
514
+ expect(result).toContain("Repository Guidelines");
515
+ expect(result).toContain("bun run lint:fix");
516
+ expect(result).toContain("bun test");
517
+ });
518
+ });
519
+
520
+ describe("getBasePrompt — local providers unaffected", () => {
521
+ test("local provider uses generic worker composite", async () => {
522
+ const result = await getBasePrompt({
523
+ ...minimalArgs,
524
+ traits: { hasMcp: true, hasLocalEnvironment: true },
525
+ });
526
+ expect(result).toContain("store-progress");
527
+ expect(result).toContain("/workspace");
528
+ });
529
+
530
+ test("undefined traits defaults to local provider behavior", async () => {
531
+ const result = await getBasePrompt(minimalArgs);
532
+ expect(result).toContain("store-progress");
533
+ expect(result).toContain("/workspace");
534
+ });
535
+ });