@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.
- package/LICENSE +21 -0
- package/README.md +115 -17
- package/index.ts +57 -22
- package/openclaw.plugin.json +37 -4
- package/package.json +2 -1
- package/prompts.yaml +47 -0
- package/src/api/linear-api.test.ts +494 -0
- package/src/api/linear-api.ts +193 -19
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +284 -29
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/doctor.test.ts +4 -4
- package/src/infra/doctor.ts +7 -29
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.ts +85 -0
- package/src/infra/notify.test.ts +357 -108
- package/src/infra/notify.ts +222 -43
- package/src/infra/observability.ts +48 -0
- package/src/infra/resilience.test.ts +94 -0
- package/src/infra/resilience.ts +101 -0
- package/src/pipeline/artifacts.ts +38 -2
- package/src/pipeline/dag-dispatch.test.ts +553 -0
- package/src/pipeline/dag-dispatch.ts +390 -0
- package/src/pipeline/dispatch-service.ts +48 -1
- package/src/pipeline/dispatch-state.ts +2 -42
- package/src/pipeline/pipeline.ts +91 -17
- package/src/pipeline/planner.test.ts +334 -0
- package/src/pipeline/planner.ts +287 -0
- package/src/pipeline/planning-state.test.ts +236 -0
- package/src/pipeline/planning-state.ts +178 -0
- package/src/pipeline/tier-assess.test.ts +175 -0
- package/src/pipeline/webhook.ts +90 -17
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
- package/src/tools/planner-tools.test.ts +535 -0
- 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
|
+
});
|
package/src/pipeline/webhook.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
+
});
|