@calltelemetry/openclaw-linear 0.6.1 → 0.7.1

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -17
  3. package/index.ts +57 -22
  4. package/openclaw.plugin.json +37 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +47 -0
  7. package/src/api/linear-api.test.ts +494 -0
  8. package/src/api/linear-api.ts +193 -19
  9. package/src/gateway/dispatch-methods.ts +243 -0
  10. package/src/infra/cli.ts +284 -29
  11. package/src/infra/codex-worktree.ts +83 -0
  12. package/src/infra/commands.ts +156 -0
  13. package/src/infra/doctor.test.ts +4 -4
  14. package/src/infra/doctor.ts +7 -29
  15. package/src/infra/file-lock.test.ts +61 -0
  16. package/src/infra/file-lock.ts +49 -0
  17. package/src/infra/multi-repo.ts +85 -0
  18. package/src/infra/notify.test.ts +357 -108
  19. package/src/infra/notify.ts +222 -43
  20. package/src/infra/observability.ts +48 -0
  21. package/src/infra/resilience.test.ts +94 -0
  22. package/src/infra/resilience.ts +101 -0
  23. package/src/pipeline/artifacts.ts +38 -2
  24. package/src/pipeline/dag-dispatch.test.ts +553 -0
  25. package/src/pipeline/dag-dispatch.ts +390 -0
  26. package/src/pipeline/dispatch-service.ts +48 -1
  27. package/src/pipeline/dispatch-state.ts +2 -42
  28. package/src/pipeline/pipeline.ts +91 -17
  29. package/src/pipeline/planner.test.ts +334 -0
  30. package/src/pipeline/planner.ts +287 -0
  31. package/src/pipeline/planning-state.test.ts +236 -0
  32. package/src/pipeline/planning-state.ts +178 -0
  33. package/src/pipeline/tier-assess.test.ts +175 -0
  34. package/src/pipeline/webhook.ts +90 -17
  35. package/src/tools/dispatch-history-tool.ts +201 -0
  36. package/src/tools/orchestration-tools.test.ts +158 -0
  37. package/src/tools/planner-tools.test.ts +535 -0
  38. package/src/tools/planner-tools.ts +450 -0
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { TIER_MODELS, assessTier, type IssueContext } from "./tier-assess.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Mock runAgent
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const mockRunAgent = vi.fn();
9
+
10
+ vi.mock("../agent/agent.js", () => ({
11
+ runAgent: (...args: unknown[]) => mockRunAgent(...args),
12
+ }));
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function makeApi(overrides?: { defaultAgentId?: string }) {
19
+ return {
20
+ logger: {
21
+ info: vi.fn(),
22
+ warn: vi.fn(),
23
+ error: vi.fn(),
24
+ },
25
+ pluginConfig: {
26
+ defaultAgentId: overrides?.defaultAgentId,
27
+ },
28
+ } as any;
29
+ }
30
+
31
+ function makeIssue(overrides?: Partial<IssueContext>): IssueContext {
32
+ return {
33
+ identifier: "CT-123",
34
+ title: "Fix login bug",
35
+ description: "Users cannot log in when using SSO",
36
+ labels: ["bug"],
37
+ commentCount: 2,
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Tests
44
+ // ---------------------------------------------------------------------------
45
+
46
+ describe("TIER_MODELS", () => {
47
+ it("maps junior to haiku, medior to sonnet, senior to opus", () => {
48
+ expect(TIER_MODELS.junior).toBe("anthropic/claude-haiku-4-5");
49
+ expect(TIER_MODELS.medior).toBe("anthropic/claude-sonnet-4-6");
50
+ expect(TIER_MODELS.senior).toBe("anthropic/claude-opus-4-6");
51
+ });
52
+ });
53
+
54
+ describe("assessTier", () => {
55
+ beforeEach(() => {
56
+ mockRunAgent.mockReset();
57
+ });
58
+
59
+ it("returns parsed tier from agent response", async () => {
60
+ mockRunAgent.mockResolvedValue({
61
+ success: true,
62
+ output: '{"tier":"senior","reasoning":"Multi-service architecture change"}',
63
+ });
64
+
65
+ const api = makeApi({ defaultAgentId: "mal" });
66
+ const result = await assessTier(api, makeIssue());
67
+
68
+ expect(result.tier).toBe("senior");
69
+ expect(result.model).toBe(TIER_MODELS.senior);
70
+ expect(result.reasoning).toBe("Multi-service architecture change");
71
+ expect(api.logger.info).toHaveBeenCalled();
72
+ });
73
+
74
+ it("falls back to medior when agent fails (success: false) with no parseable JSON", async () => {
75
+ mockRunAgent.mockResolvedValue({
76
+ success: false,
77
+ output: "Agent process exited with code 1",
78
+ });
79
+
80
+ const api = makeApi({ defaultAgentId: "mal" });
81
+ const result = await assessTier(api, makeIssue());
82
+
83
+ expect(result.tier).toBe("medior");
84
+ expect(result.model).toBe(TIER_MODELS.medior);
85
+ expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
86
+ expect(api.logger.warn).toHaveBeenCalled();
87
+ });
88
+
89
+ it("falls back to medior when output has no JSON", async () => {
90
+ mockRunAgent.mockResolvedValue({
91
+ success: true,
92
+ output: "I think this is a medium complexity issue because it involves multiple files.",
93
+ });
94
+
95
+ const api = makeApi({ defaultAgentId: "mal" });
96
+ const result = await assessTier(api, makeIssue());
97
+
98
+ expect(result.tier).toBe("medior");
99
+ expect(result.model).toBe(TIER_MODELS.medior);
100
+ expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
101
+ });
102
+
103
+ it("falls back to medior when JSON has invalid tier", async () => {
104
+ mockRunAgent.mockResolvedValue({
105
+ success: true,
106
+ output: '{"tier":"expert","reasoning":"Very hard problem"}',
107
+ });
108
+
109
+ const api = makeApi({ defaultAgentId: "mal" });
110
+ const result = await assessTier(api, makeIssue());
111
+
112
+ expect(result.tier).toBe("medior");
113
+ expect(result.model).toBe(TIER_MODELS.medior);
114
+ });
115
+
116
+ it("handles agent throwing an error", async () => {
117
+ mockRunAgent.mockRejectedValue(new Error("Connection refused"));
118
+
119
+ const api = makeApi({ defaultAgentId: "mal" });
120
+ const result = await assessTier(api, makeIssue());
121
+
122
+ expect(result.tier).toBe("medior");
123
+ expect(result.model).toBe(TIER_MODELS.medior);
124
+ expect(result.reasoning).toBe("Assessment failed — defaulting to medior");
125
+ expect(api.logger.warn).toHaveBeenCalledWith(
126
+ expect.stringContaining("Tier assessment error for CT-123"),
127
+ );
128
+ });
129
+
130
+ it("truncates long descriptions to 1500 chars", async () => {
131
+ const longDescription = "A".repeat(3000);
132
+ mockRunAgent.mockResolvedValue({
133
+ success: true,
134
+ output: '{"tier":"junior","reasoning":"Simple copy change"}',
135
+ });
136
+
137
+ const api = makeApi({ defaultAgentId: "mal" });
138
+ await assessTier(api, makeIssue({ description: longDescription }));
139
+
140
+ // Verify the message sent to runAgent has the description truncated
141
+ const callArgs = mockRunAgent.mock.calls[0][0];
142
+ const message: string = callArgs.message;
143
+ // The description in the prompt should be at most 1500 chars of the original
144
+ // "Description: " prefix + 1500 chars = the truncated form
145
+ expect(message).toContain("Description: " + "A".repeat(1500));
146
+ expect(message).not.toContain("A".repeat(1501));
147
+ });
148
+
149
+ it("uses configured agentId when provided", async () => {
150
+ mockRunAgent.mockResolvedValue({
151
+ success: true,
152
+ output: '{"tier":"junior","reasoning":"Typo fix"}',
153
+ });
154
+
155
+ const api = makeApi({ defaultAgentId: "mal" });
156
+ await assessTier(api, makeIssue(), "kaylee");
157
+
158
+ const callArgs = mockRunAgent.mock.calls[0][0];
159
+ expect(callArgs.agentId).toBe("kaylee");
160
+ });
161
+
162
+ it("parses JSON even when wrapped in markdown fences", async () => {
163
+ mockRunAgent.mockResolvedValue({
164
+ success: true,
165
+ output: '```json\n{"tier":"junior","reasoning":"Config tweak"}\n```',
166
+ });
167
+
168
+ const api = makeApi({ defaultAgentId: "mal" });
169
+ const result = await assessTier(api, makeIssue());
170
+
171
+ expect(result.tier).toBe("junior");
172
+ expect(result.model).toBe(TIER_MODELS.junior);
173
+ expect(result.reasoning).toBe("Config tweak");
174
+ });
175
+ });
@@ -6,10 +6,14 @@ import { LinearAgentApi, resolveLinearToken } from "../api/linear-api.js";
6
6
  import { spawnWorker, type HookContext } from "./pipeline.js";
7
7
  import { setActiveSession, clearActiveSession } from "./active-session.js";
8
8
  import { readDispatchState, getActiveDispatch, registerDispatch, updateDispatchStatus, completeDispatch, removeActiveDispatch } from "./dispatch-state.js";
9
- import { createDiscordNotifier, createNoopNotifier, type NotifyFn } from "../infra/notify.js";
9
+ import { createNotifierFromConfig, type NotifyFn } from "../infra/notify.js";
10
10
  import { assessTier } from "./tier-assess.js";
11
11
  import { createWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
12
12
  import { ensureClawDir, writeManifest } from "./artifacts.js";
13
+ import { readPlanningState, isInPlanningMode, getPlanningSession } from "./planning-state.js";
14
+ import { initiatePlanningSession, handlePlannerTurn } from "./planner.js";
15
+ import { startProjectDispatch } from "./dag-dispatch.js";
16
+ import { emitDiagnostic } from "../infra/observability.js";
13
17
 
14
18
  // ── Agent profiles (loaded from config, no hardcoded names) ───────
15
19
  interface AgentProfile {
@@ -146,6 +150,7 @@ export async function handleLinearWebhook(
146
150
  // Debug: log full payload structure for diagnosing webhook types
147
151
  const payloadKeys = Object.keys(payload).join(", ");
148
152
  api.logger.info(`Linear webhook received: type=${payload.type} action=${payload.action} keys=[${payloadKeys}]`);
153
+ emitDiagnostic(api, { event: "webhook_received", webhookType: payload.type, webhookAction: payload.action });
149
154
 
150
155
 
151
156
  // ── AppUserNotification — OAuth app webhook for agent mentions/assignments
@@ -663,6 +668,66 @@ export async function handleLinearWebhook(
663
668
  const commentor = comment?.user?.name ?? "Unknown";
664
669
  const issue = comment?.issue ?? payload.issue;
665
670
 
671
+ // ── Planning mode intercept ──────────────────────────────────
672
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
673
+
674
+ if (issue?.id) {
675
+ const linearApiForPlanning = createLinearApi(api);
676
+ if (linearApiForPlanning) {
677
+ try {
678
+ const enriched = await linearApiForPlanning.getIssueDetails(issue.id);
679
+ const projectId = enriched?.project?.id;
680
+ const planStatePath = pluginConfig?.planningStatePath as string | undefined;
681
+
682
+ if (projectId) {
683
+ const planState = await readPlanningState(planStatePath);
684
+
685
+ // Check if this is a plan initiation request
686
+ const isPlanRequest = /\b(plan|planning)\s+(this\s+)?(project|out)\b/i.test(commentBody);
687
+ if (isPlanRequest && !isInPlanningMode(planState, projectId)) {
688
+ api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
689
+ void initiatePlanningSession(
690
+ { api, linearApi: linearApiForPlanning, pluginConfig },
691
+ projectId,
692
+ { id: issue.id, identifier: enriched.identifier, title: enriched.title, team: enriched.team },
693
+ ).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
694
+ return true;
695
+ }
696
+
697
+ // Route to planner if project is in planning mode
698
+ if (isInPlanningMode(planState, projectId)) {
699
+ const session = getPlanningSession(planState, projectId);
700
+ if (session && comment?.id && !wasRecentlyProcessed(`plan-comment:${comment.id}`)) {
701
+ api.logger.info(`Planning: routing comment to planner for ${session.projectName}`);
702
+ void handlePlannerTurn(
703
+ { api, linearApi: linearApiForPlanning, pluginConfig },
704
+ session,
705
+ { issueId: issue.id, commentBody, commentorName: commentor },
706
+ {
707
+ onApproved: (approvedProjectId) => {
708
+ const notify = createNotifierFromConfig(pluginConfig, api.runtime);
709
+ const hookCtx: HookContext = {
710
+ api,
711
+ linearApi: linearApiForPlanning,
712
+ notify,
713
+ pluginConfig,
714
+ configPath: pluginConfig?.dispatchStatePath as string | undefined,
715
+ };
716
+ void startProjectDispatch(hookCtx, approvedProjectId)
717
+ .catch((err) => api.logger.error(`Project dispatch start error: ${err}`));
718
+ },
719
+ },
720
+ ).catch((err) => api.logger.error(`Planner turn error: ${err}`));
721
+ }
722
+ return true;
723
+ }
724
+ }
725
+ } catch (err) {
726
+ api.logger.warn(`Planning mode check failed: ${err}`);
727
+ }
728
+ }
729
+ }
730
+
666
731
  // Load agent profiles and build mention pattern dynamically.
667
732
  // Default agent (app mentions) is handled by AgentSessionEvent — never here.
668
733
  const profiles = loadAgentProfiles();
@@ -1161,6 +1226,26 @@ async function handleDispatch(
1161
1226
 
1162
1227
  api.logger.info(`@dispatch: processing ${identifier}`);
1163
1228
 
1229
+ // 0. Check planning mode — prevent dispatch for issues in planning-mode projects
1230
+ try {
1231
+ const enrichedForPlan = await linearApi.getIssueDetails(issue.id ?? issue);
1232
+ const planProjectId = enrichedForPlan?.project?.id;
1233
+ if (planProjectId) {
1234
+ const planStatePath = pluginConfig?.planningStatePath as string | undefined;
1235
+ const planState = await readPlanningState(planStatePath);
1236
+ if (isInPlanningMode(planState, planProjectId)) {
1237
+ api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
1238
+ await linearApi.createComment(
1239
+ issue.id,
1240
+ "This project is in planning mode. Finalize the plan before dispatching implementation.",
1241
+ );
1242
+ return;
1243
+ }
1244
+ }
1245
+ } catch (err) {
1246
+ api.logger.warn(`dispatch: planning mode check failed for ${identifier}: ${err}`);
1247
+ }
1248
+
1164
1249
  // 1. Check for existing active dispatch — reclaim if stale
1165
1250
  const STALE_DISPATCH_MS = 30 * 60_000; // 30 min without a gateway holding it = stale
1166
1251
  const state = await readDispatchState(statePath);
@@ -1217,6 +1302,7 @@ async function handleDispatch(
1217
1302
  });
1218
1303
 
1219
1304
  api.logger.info(`@dispatch: ${identifier} assessed as ${assessment.tier} (${assessment.model}) — ${assessment.reasoning}`);
1305
+ emitDiagnostic(api, { event: "dispatch_started", identifier, tier: assessment.tier, issueId: issue.id });
1220
1306
 
1221
1307
  // 5. Create persistent worktree
1222
1308
  let worktree;
@@ -1285,6 +1371,7 @@ async function handleDispatch(
1285
1371
  dispatchedAt: now,
1286
1372
  agentSessionId,
1287
1373
  attempt: 0,
1374
+ project: enrichedIssue?.project?.id,
1288
1375
  }, statePath);
1289
1376
 
1290
1377
  // 8. Register active session for tool resolution
@@ -1337,22 +1424,8 @@ async function handleDispatch(
1337
1424
  // 11. Run v2 pipeline: worker → audit → verdict (non-blocking)
1338
1425
  activeRuns.add(issue.id);
1339
1426
 
1340
- // Instantiate notifier
1341
- const discordBotToken = (() => {
1342
- try {
1343
- const config = JSON.parse(
1344
- require("node:fs").readFileSync(
1345
- require("node:path").join(process.env.HOME ?? "/home/claw", ".openclaw", "openclaw.json"),
1346
- "utf8",
1347
- ),
1348
- );
1349
- return config?.channels?.discord?.token as string | undefined;
1350
- } catch { return undefined; }
1351
- })();
1352
- const flowDiscordChannel = pluginConfig?.flowDiscordChannel as string | undefined;
1353
- const notify: NotifyFn = (discordBotToken && flowDiscordChannel)
1354
- ? createDiscordNotifier(discordBotToken, flowDiscordChannel)
1355
- : createNoopNotifier();
1427
+ // Instantiate notifier (Discord, Slack, or both — config-driven)
1428
+ const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime);
1356
1429
 
1357
1430
  const hookCtx: HookContext = {
1358
1431
  api,
@@ -0,0 +1,201 @@
1
+ /**
2
+ * dispatch-history-tool.ts — Agent tool for searching dispatch history.
3
+ *
4
+ * Searches dispatch state + memory files to provide context about
5
+ * past dispatches. Useful for agents to understand what work has
6
+ * been done on related issues.
7
+ */
8
+ import { readdirSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
11
+ import { jsonResult } from "openclaw/plugin-sdk";
12
+ import { readDispatchState, listActiveDispatches, type ActiveDispatch, type CompletedDispatch } from "../pipeline/dispatch-state.js";
13
+ import { resolveOrchestratorWorkspace } from "../pipeline/artifacts.js";
14
+
15
+ export function createDispatchHistoryTool(
16
+ api: OpenClawPluginApi,
17
+ pluginConfig?: Record<string, unknown>,
18
+ ): AnyAgentTool {
19
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
20
+
21
+ return {
22
+ name: "dispatch_history",
23
+ label: "Dispatch History",
24
+ description:
25
+ "Search dispatch history for past and active Linear issue dispatches. " +
26
+ "Returns issue identifier, tier, status, attempts, and summary excerpts.",
27
+ parameters: {
28
+ type: "object",
29
+ properties: {
30
+ query: {
31
+ type: "string",
32
+ description: "Issue identifier (e.g. 'CT-123') or keyword to search in summaries.",
33
+ },
34
+ tier: {
35
+ type: "string",
36
+ enum: ["junior", "medior", "senior"],
37
+ description: "Filter by tier.",
38
+ },
39
+ status: {
40
+ type: "string",
41
+ enum: ["dispatched", "working", "auditing", "done", "failed", "stuck"],
42
+ description: "Filter by status.",
43
+ },
44
+ limit: {
45
+ type: "number",
46
+ description: "Max results to return (default: 10).",
47
+ },
48
+ },
49
+ },
50
+ execute: async (_toolCallId: string, params: {
51
+ query?: string;
52
+ tier?: string;
53
+ status?: string;
54
+ limit?: number;
55
+ }) => {
56
+ const maxResults = params.limit ?? 10;
57
+ const results: Array<{
58
+ identifier: string;
59
+ tier: string;
60
+ status: string;
61
+ attempts: number;
62
+ summary?: string;
63
+ active: boolean;
64
+ }> = [];
65
+
66
+ // Search active dispatches
67
+ const state = await readDispatchState(statePath);
68
+ const active = listActiveDispatches(state);
69
+ for (const d of active) {
70
+ if (matchesFilters(d.issueIdentifier, d.tier, d.status, params)) {
71
+ results.push({
72
+ identifier: d.issueIdentifier,
73
+ tier: d.tier,
74
+ status: d.status,
75
+ attempts: d.attempt,
76
+ active: true,
77
+ });
78
+ }
79
+ }
80
+
81
+ // Search completed dispatches
82
+ for (const [id, d] of Object.entries(state.dispatches.completed)) {
83
+ if (matchesFilters(id, d.tier, d.status, params)) {
84
+ results.push({
85
+ identifier: id,
86
+ tier: d.tier,
87
+ status: d.status,
88
+ attempts: d.totalAttempts ?? 0,
89
+ active: false,
90
+ });
91
+ }
92
+ }
93
+
94
+ // Search memory files for richer context
95
+ try {
96
+ const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
97
+ const memDir = join(wsDir, "memory");
98
+ const files = readdirSync(memDir).filter(f => f.startsWith("dispatch-") && f.endsWith(".md"));
99
+
100
+ for (const file of files) {
101
+ const id = file.replace("dispatch-", "").replace(".md", "");
102
+ // Skip if already in results
103
+ if (results.some(r => r.identifier === id)) {
104
+ // Enrich with summary
105
+ const existing = results.find(r => r.identifier === id);
106
+ if (existing && !existing.summary) {
107
+ try {
108
+ const content = readFileSync(join(memDir, file), "utf-8");
109
+ existing.summary = extractSummaryExcerpt(content, params.query);
110
+ } catch {}
111
+ }
112
+ continue;
113
+ }
114
+
115
+ // Check if memory file matches query
116
+ if (params.query) {
117
+ try {
118
+ const content = readFileSync(join(memDir, file), "utf-8");
119
+ if (
120
+ id.toLowerCase().includes(params.query.toLowerCase()) ||
121
+ content.toLowerCase().includes(params.query.toLowerCase())
122
+ ) {
123
+ const meta = parseFrontmatter(content);
124
+ if (matchesFilters(id, meta.tier, meta.status, params)) {
125
+ results.push({
126
+ identifier: id,
127
+ tier: meta.tier ?? "unknown",
128
+ status: meta.status ?? "completed",
129
+ attempts: meta.attempts ?? 0,
130
+ summary: extractSummaryExcerpt(content, params.query),
131
+ active: false,
132
+ });
133
+ }
134
+ }
135
+ } catch {}
136
+ }
137
+ }
138
+ } catch {}
139
+
140
+ const limited = results.slice(0, maxResults);
141
+
142
+ if (limited.length === 0) {
143
+ return jsonResult({ message: "No dispatch history found matching the criteria.", results: [] });
144
+ }
145
+
146
+ const formatted = limited.map(r => {
147
+ const parts = [`**${r.identifier}** — ${r.status} (${r.tier}, ${r.attempts} attempts)${r.active ? " [ACTIVE]" : ""}`];
148
+ if (r.summary) parts.push(` ${r.summary}`);
149
+ return parts.join("\n");
150
+ }).join("\n\n");
151
+
152
+ return jsonResult({
153
+ message: `Found ${limited.length} dispatch(es):\n\n${formatted}`,
154
+ results: limited,
155
+ });
156
+ },
157
+ } as unknown as AnyAgentTool;
158
+ }
159
+
160
+ function matchesFilters(
161
+ identifier: string,
162
+ tier: string,
163
+ status: string,
164
+ params: { query?: string; tier?: string; status?: string },
165
+ ): boolean {
166
+ if (params.tier && tier !== params.tier) return false;
167
+ if (params.status && status !== params.status) return false;
168
+ if (params.query && !identifier.toLowerCase().includes(params.query.toLowerCase())) return false;
169
+ return true;
170
+ }
171
+
172
+ function parseFrontmatter(content: string): Record<string, any> {
173
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
174
+ if (!match) return {};
175
+ const result: Record<string, any> = {};
176
+ for (const line of match[1].split("\n")) {
177
+ const [key, ...rest] = line.split(": ");
178
+ if (key && rest.length > 0) {
179
+ let value: any = rest.join(": ").trim();
180
+ if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
181
+ else if (!isNaN(Number(value))) value = Number(value);
182
+ result[key.trim()] = value;
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+
188
+ function extractSummaryExcerpt(content: string, query?: string): string {
189
+ // Remove frontmatter
190
+ const body = content.replace(/^---[\s\S]*?---\n?/, "").trim();
191
+ if (!query) return body.slice(0, 200);
192
+
193
+ // Find the query in the body and return surrounding context
194
+ const lower = body.toLowerCase();
195
+ const idx = lower.indexOf(query.toLowerCase());
196
+ if (idx === -1) return body.slice(0, 200);
197
+
198
+ const start = Math.max(0, idx - 50);
199
+ const end = Math.min(body.length, idx + query.length + 150);
200
+ return (start > 0 ? "..." : "") + body.slice(start, end) + (end < body.length ? "..." : "");
201
+ }
@@ -0,0 +1,158 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { createOrchestrationTools } from "./orchestration-tools.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Mock runAgent
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const mockRunAgent = vi.fn();
9
+
10
+ vi.mock("../agent/agent.js", () => ({
11
+ runAgent: (...args: unknown[]) => mockRunAgent(...args),
12
+ }));
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Mock jsonResult (from openclaw/plugin-sdk)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ vi.mock("openclaw/plugin-sdk", () => ({
19
+ jsonResult: (obj: unknown) => ({ type: "json", data: obj }),
20
+ }));
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function makeApi() {
27
+ return {
28
+ logger: {
29
+ info: vi.fn(),
30
+ warn: vi.fn(),
31
+ error: vi.fn(),
32
+ },
33
+ } as any;
34
+ }
35
+
36
+ function makeCtx(): Record<string, unknown> {
37
+ return {};
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Tests
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe("createOrchestrationTools", () => {
45
+ beforeEach(() => {
46
+ mockRunAgent.mockReset();
47
+ });
48
+
49
+ it("returns 2 tools", () => {
50
+ const tools = createOrchestrationTools(makeApi(), makeCtx());
51
+
52
+ expect(tools).toHaveLength(2);
53
+ expect(tools[0].name).toBe("spawn_agent");
54
+ expect(tools[1].name).toBe("ask_agent");
55
+ });
56
+
57
+ it("spawn_agent tool has correct name and parameters schema", () => {
58
+ const tools = createOrchestrationTools(makeApi(), makeCtx());
59
+ const spawn = tools[0];
60
+
61
+ expect(spawn.name).toBe("spawn_agent");
62
+ expect(spawn.parameters).toBeDefined();
63
+ expect(spawn.parameters.type).toBe("object");
64
+ expect(spawn.parameters.properties.agentId).toBeDefined();
65
+ expect(spawn.parameters.properties.task).toBeDefined();
66
+ expect(spawn.parameters.properties.timeoutSeconds).toBeDefined();
67
+ expect(spawn.parameters.required).toEqual(["agentId", "task"]);
68
+ });
69
+
70
+ it("spawn_agent dispatches to runAgent and returns immediately", async () => {
71
+ // runAgent returns a promise that never resolves (to prove fire-and-forget)
72
+ let resolveAgent!: (value: unknown) => void;
73
+ const agentPromise = new Promise((resolve) => { resolveAgent = resolve; });
74
+ mockRunAgent.mockReturnValue(agentPromise);
75
+
76
+ const api = makeApi();
77
+ const tools = createOrchestrationTools(api, makeCtx());
78
+ const spawn = tools[0];
79
+
80
+ const result = await spawn.execute("call-1", {
81
+ agentId: "kaylee",
82
+ task: "Investigate database performance",
83
+ });
84
+
85
+ // Should return immediately with a sessionId, even though runAgent hasn't resolved
86
+ expect(result.data.agentId).toBe("kaylee");
87
+ expect(result.data.sessionId).toMatch(/^spawn-kaylee-/);
88
+ expect(result.data.message).toContain("Dispatched task");
89
+ expect(mockRunAgent).toHaveBeenCalledOnce();
90
+
91
+ // Clean up: resolve the hanging promise to avoid unhandled rejection
92
+ resolveAgent({ success: true, output: "done" });
93
+ });
94
+
95
+ it("ask_agent returns response on success", async () => {
96
+ mockRunAgent.mockResolvedValue({
97
+ success: true,
98
+ output: "No, the schema change is backward-compatible and won't break tests.",
99
+ });
100
+
101
+ const api = makeApi();
102
+ const tools = createOrchestrationTools(api, makeCtx());
103
+ const askAgent = tools[1];
104
+
105
+ const result = await askAgent.execute("call-2", {
106
+ agentId: "kaylee",
107
+ message: "Would this schema change break existing tests?",
108
+ });
109
+
110
+ expect(result.data.agentId).toBe("kaylee");
111
+ expect(result.data.response).toBe(
112
+ "No, the schema change is backward-compatible and won't break tests.",
113
+ );
114
+ expect(result.data.message).toContain("Response from agent");
115
+ });
116
+
117
+ it("ask_agent returns error message on failure", async () => {
118
+ mockRunAgent.mockResolvedValue({
119
+ success: false,
120
+ output: "Agent timed out after 120s",
121
+ });
122
+
123
+ const api = makeApi();
124
+ const tools = createOrchestrationTools(api, makeCtx());
125
+ const askAgent = tools[1];
126
+
127
+ const result = await askAgent.execute("call-3", {
128
+ agentId: "kaylee",
129
+ message: "Check if the migration works",
130
+ });
131
+
132
+ expect(result.data.agentId).toBe("kaylee");
133
+ expect(result.data.error).toBe("Agent timed out after 120s");
134
+ expect(result.data.message).toContain("failed to respond");
135
+ expect(result.data.response).toBeUndefined();
136
+ });
137
+
138
+ it("ask_agent uses custom timeout when provided", async () => {
139
+ mockRunAgent.mockResolvedValue({
140
+ success: true,
141
+ output: "Result from agent",
142
+ });
143
+
144
+ const api = makeApi();
145
+ const tools = createOrchestrationTools(api, makeCtx());
146
+ const askAgent = tools[1];
147
+
148
+ await askAgent.execute("call-4", {
149
+ agentId: "mal",
150
+ message: "Run a long analysis",
151
+ timeoutSeconds: 600,
152
+ });
153
+
154
+ const callArgs = mockRunAgent.mock.calls[0][0];
155
+ // 600 seconds = 600_000 ms
156
+ expect(callArgs.timeoutMs).toBe(600_000);
157
+ });
158
+ });