@calltelemetry/openclaw-linear 0.8.2 → 0.8.3
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 +28 -2
- package/package.json +1 -1
- package/src/__test__/fixtures/webhook-payloads.ts +93 -0
- package/src/__test__/smoke-linear-api.test.ts +352 -0
- package/src/__test__/webhook-scenarios.test.ts +570 -0
- package/src/agent/agent.ts +69 -5
- package/src/api/linear-api.test.ts +37 -0
- package/src/api/linear-api.ts +96 -5
- package/src/infra/cli.ts +150 -0
- package/src/infra/doctor.test.ts +17 -2
- package/src/infra/doctor.ts +70 -1
- package/src/infra/webhook-provision.test.ts +162 -0
- package/src/infra/webhook-provision.ts +152 -0
- package/src/pipeline/webhook-dedup.test.ts +466 -0
- package/src/pipeline/webhook.ts +200 -114
- package/src/tools/tools.test.ts +100 -0
package/src/pipeline/webhook.ts
CHANGED
|
@@ -29,10 +29,20 @@ interface AgentProfile {
|
|
|
29
29
|
|
|
30
30
|
const PROFILES_PATH = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
|
|
31
31
|
|
|
32
|
+
// ── Cached profile loader (5s TTL) ─────────────────────────────────
|
|
33
|
+
let profilesCache: { data: Record<string, AgentProfile>; loadedAt: number } | null = null;
|
|
34
|
+
const PROFILES_CACHE_TTL_MS = 5_000;
|
|
35
|
+
|
|
32
36
|
function loadAgentProfiles(): Record<string, AgentProfile> {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
if (profilesCache && now - profilesCache.loadedAt < PROFILES_CACHE_TTL_MS) {
|
|
39
|
+
return profilesCache.data;
|
|
40
|
+
}
|
|
33
41
|
try {
|
|
34
42
|
const raw = readFileSync(PROFILES_PATH, "utf8");
|
|
35
|
-
|
|
43
|
+
const data = JSON.parse(raw).agents ?? {};
|
|
44
|
+
profilesCache = { data, loadedAt: now };
|
|
45
|
+
return data;
|
|
36
46
|
} catch {
|
|
37
47
|
return {};
|
|
38
48
|
}
|
|
@@ -64,19 +74,45 @@ function resolveAgentFromAlias(alias: string, profiles: Record<string, AgentProf
|
|
|
64
74
|
// Track issues with active agent runs to prevent concurrent duplicate runs.
|
|
65
75
|
const activeRuns = new Set<string>();
|
|
66
76
|
|
|
67
|
-
// Dedup: track recently processed keys to avoid double-handling
|
|
77
|
+
// Dedup: track recently processed keys to avoid double-handling.
|
|
78
|
+
// Periodic sweep (every 10s) instead of O(n) scan on every call.
|
|
68
79
|
const recentlyProcessed = new Map<string, number>();
|
|
80
|
+
const DEDUP_TTL_MS = 60_000;
|
|
81
|
+
const SWEEP_INTERVAL_MS = 10_000;
|
|
82
|
+
let lastSweep = Date.now();
|
|
83
|
+
|
|
69
84
|
function wasRecentlyProcessed(key: string): boolean {
|
|
70
85
|
const now = Date.now();
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
86
|
+
if (now - lastSweep > SWEEP_INTERVAL_MS) {
|
|
87
|
+
for (const [k, ts] of recentlyProcessed) {
|
|
88
|
+
if (now - ts > DEDUP_TTL_MS) recentlyProcessed.delete(k);
|
|
89
|
+
}
|
|
90
|
+
lastSweep = now;
|
|
74
91
|
}
|
|
75
92
|
if (recentlyProcessed.has(key)) return true;
|
|
76
93
|
recentlyProcessed.set(key, now);
|
|
77
94
|
return false;
|
|
78
95
|
}
|
|
79
96
|
|
|
97
|
+
/** @internal — test-only; clears all in-memory dedup state. */
|
|
98
|
+
export function _resetForTesting(): void {
|
|
99
|
+
activeRuns.clear();
|
|
100
|
+
recentlyProcessed.clear();
|
|
101
|
+
profilesCache = null;
|
|
102
|
+
linearApiCache = null;
|
|
103
|
+
lastSweep = Date.now();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @internal — test-only; add an issue ID to the activeRuns set. */
|
|
107
|
+
export function _addActiveRunForTesting(issueId: string): void {
|
|
108
|
+
activeRuns.add(issueId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @internal — test-only; pre-registers a key as recently processed. */
|
|
112
|
+
export function _markAsProcessedForTesting(key: string): void {
|
|
113
|
+
wasRecentlyProcessed(key);
|
|
114
|
+
}
|
|
115
|
+
|
|
80
116
|
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
|
81
117
|
const chunks: Buffer[] = [];
|
|
82
118
|
let total = 0;
|
|
@@ -101,7 +137,16 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
|
|
101
137
|
});
|
|
102
138
|
}
|
|
103
139
|
|
|
140
|
+
// ── Cached LinearApi instance (30s TTL) ────────────────────────────
|
|
141
|
+
let linearApiCache: { instance: LinearAgentApi; createdAt: number } | null = null;
|
|
142
|
+
const LINEAR_API_CACHE_TTL_MS = 30_000;
|
|
143
|
+
|
|
104
144
|
function createLinearApi(api: OpenClawPluginApi): LinearAgentApi | null {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
if (linearApiCache && now - linearApiCache.createdAt < LINEAR_API_CACHE_TTL_MS) {
|
|
147
|
+
return linearApiCache.instance;
|
|
148
|
+
}
|
|
149
|
+
|
|
105
150
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
106
151
|
const resolved = resolveLinearToken(pluginConfig);
|
|
107
152
|
|
|
@@ -110,12 +155,54 @@ function createLinearApi(api: OpenClawPluginApi): LinearAgentApi | null {
|
|
|
110
155
|
const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
|
|
111
156
|
const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
|
|
112
157
|
|
|
113
|
-
|
|
158
|
+
const instance = new LinearAgentApi(resolved.accessToken, {
|
|
114
159
|
refreshToken: resolved.refreshToken,
|
|
115
160
|
expiresAt: resolved.expiresAt,
|
|
116
161
|
clientId: clientId ?? undefined,
|
|
117
162
|
clientSecret: clientSecret ?? undefined,
|
|
118
163
|
});
|
|
164
|
+
linearApiCache = { instance, createdAt: now };
|
|
165
|
+
return instance;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Comment wrapper that pre-registers comment ID for dedup ────────
|
|
169
|
+
// When we create a comment, Linear fires Comment.create webhook back to us.
|
|
170
|
+
// Register the comment ID immediately so the webhook handler skips it.
|
|
171
|
+
// The `opts` parameter posts as a named OpenClaw agent identity (e.g.
|
|
172
|
+
// createAsUser: "Mal" with avatar) — requires OAuth actor=app scope.
|
|
173
|
+
async function createCommentWithDedup(
|
|
174
|
+
linearApi: LinearAgentApi,
|
|
175
|
+
issueId: string,
|
|
176
|
+
body: string,
|
|
177
|
+
opts?: { createAsUser?: string; displayIconUrl?: string },
|
|
178
|
+
): Promise<string> {
|
|
179
|
+
const commentId = await linearApi.createComment(issueId, body, opts);
|
|
180
|
+
wasRecentlyProcessed(`comment:${commentId}`);
|
|
181
|
+
return commentId;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Post a comment as agent identity with prefix fallback.
|
|
186
|
+
* With gql() partial-success fix, the catch only fires for real failures.
|
|
187
|
+
*/
|
|
188
|
+
async function postAgentComment(
|
|
189
|
+
api: OpenClawPluginApi,
|
|
190
|
+
linearApi: LinearAgentApi,
|
|
191
|
+
issueId: string,
|
|
192
|
+
body: string,
|
|
193
|
+
label: string,
|
|
194
|
+
agentOpts?: { createAsUser: string; displayIconUrl: string },
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
if (!agentOpts) {
|
|
197
|
+
await createCommentWithDedup(linearApi, issueId, `**[${label}]** ${body}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
await createCommentWithDedup(linearApi, issueId, body, agentOpts);
|
|
202
|
+
} catch (identityErr) {
|
|
203
|
+
api.logger.warn(`Agent identity comment failed: ${identityErr}`);
|
|
204
|
+
await createCommentWithDedup(linearApi, issueId, `**[${label}]** ${body}`);
|
|
205
|
+
}
|
|
119
206
|
}
|
|
120
207
|
|
|
121
208
|
function resolveAgentId(api: OpenClawPluginApi): string {
|
|
@@ -186,7 +273,17 @@ export async function handleLinearWebhook(
|
|
|
186
273
|
return true;
|
|
187
274
|
}
|
|
188
275
|
|
|
189
|
-
//
|
|
276
|
+
// Guard: check activeRuns FIRST (O(1), no side effects).
|
|
277
|
+
// This catches sessions created by our own handlers (Comment dispatch,
|
|
278
|
+
// Issue triage, handleDispatch) which all set activeRuns BEFORE calling
|
|
279
|
+
// createSessionOnIssue(). Checking this first prevents the race condition
|
|
280
|
+
// where the webhook arrives before wasRecentlyProcessed is registered.
|
|
281
|
+
if (activeRuns.has(issue.id)) {
|
|
282
|
+
api.logger.info(`Agent already running for ${issue?.identifier ?? issue?.id} — skipping session ${session.id}`);
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Secondary dedup: skip if we already handled this exact session ID
|
|
190
287
|
if (wasRecentlyProcessed(`session:${session.id}`)) {
|
|
191
288
|
api.logger.info(`AgentSession ${session.id} already handled — skipping`);
|
|
192
289
|
return true;
|
|
@@ -202,13 +299,7 @@ export async function handleLinearWebhook(
|
|
|
202
299
|
const previousComments = payload.previousComments ?? [];
|
|
203
300
|
const guidance = payload.guidance;
|
|
204
301
|
|
|
205
|
-
api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`)
|
|
206
|
-
|
|
207
|
-
// Guard: skip if an agent run is already active for this issue
|
|
208
|
-
if (activeRuns.has(issue.id)) {
|
|
209
|
-
api.logger.info(`Agent already running for ${issue?.identifier ?? issue?.id} — skipping session ${session.id}`);
|
|
210
|
-
return true;
|
|
211
|
-
}
|
|
302
|
+
api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`)
|
|
212
303
|
|
|
213
304
|
// Extract the user's latest message from previousComments
|
|
214
305
|
// The last comment is the most recent user message
|
|
@@ -298,31 +389,21 @@ export async function handleLinearWebhook(
|
|
|
298
389
|
? result.output
|
|
299
390
|
: `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
|
|
300
391
|
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
: undefined;
|
|
306
|
-
|
|
307
|
-
try {
|
|
308
|
-
if (brandingOpts) {
|
|
309
|
-
await linearApi.createComment(issue.id, responseBody, brandingOpts);
|
|
310
|
-
} else {
|
|
311
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
312
|
-
}
|
|
313
|
-
} catch (brandErr) {
|
|
314
|
-
api.logger.warn(`Branded comment failed: ${brandErr}`);
|
|
315
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Emit response (closes session)
|
|
319
|
-
const truncated = responseBody.length > 2000
|
|
320
|
-
? responseBody.slice(0, 2000) + "\u2026"
|
|
321
|
-
: responseBody;
|
|
322
|
-
await linearApi.emitActivity(session.id, {
|
|
392
|
+
// Emit response via session (preferred — avoids duplicate comment).
|
|
393
|
+
// Fall back to a regular comment only if emitActivity fails.
|
|
394
|
+
const labeledResponse = `**[${label}]** ${responseBody}`;
|
|
395
|
+
const emitted = await linearApi.emitActivity(session.id, {
|
|
323
396
|
type: "response",
|
|
324
|
-
body:
|
|
325
|
-
}).catch(() =>
|
|
397
|
+
body: labeledResponse,
|
|
398
|
+
}).then(() => true).catch(() => false);
|
|
399
|
+
|
|
400
|
+
if (!emitted) {
|
|
401
|
+
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
402
|
+
const agentOpts = avatarUrl
|
|
403
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
404
|
+
: undefined;
|
|
405
|
+
await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
|
|
406
|
+
}
|
|
326
407
|
|
|
327
408
|
api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
|
|
328
409
|
} catch (err) {
|
|
@@ -476,29 +557,20 @@ export async function handleLinearWebhook(
|
|
|
476
557
|
? result.output
|
|
477
558
|
: `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
|
|
478
559
|
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
: undefined;
|
|
483
|
-
|
|
484
|
-
try {
|
|
485
|
-
if (brandingOpts) {
|
|
486
|
-
await linearApi.createComment(issue.id, responseBody, brandingOpts);
|
|
487
|
-
} else {
|
|
488
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
489
|
-
}
|
|
490
|
-
} catch (brandErr) {
|
|
491
|
-
api.logger.warn(`Branded comment failed: ${brandErr}`);
|
|
492
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
const truncated = responseBody.length > 2000
|
|
496
|
-
? responseBody.slice(0, 2000) + "\u2026"
|
|
497
|
-
: responseBody;
|
|
498
|
-
await linearApi.emitActivity(session.id, {
|
|
560
|
+
// Emit response via session (preferred). Fall back to comment if it fails.
|
|
561
|
+
const labeledResponse = `**[${label}]** ${responseBody}`;
|
|
562
|
+
const emitted = await linearApi.emitActivity(session.id, {
|
|
499
563
|
type: "response",
|
|
500
|
-
body:
|
|
501
|
-
}).catch(() =>
|
|
564
|
+
body: labeledResponse,
|
|
565
|
+
}).then(() => true).catch(() => false);
|
|
566
|
+
|
|
567
|
+
if (!emitted) {
|
|
568
|
+
const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
|
|
569
|
+
const agentOpts = avatarUrl
|
|
570
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
571
|
+
: undefined;
|
|
572
|
+
await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
|
|
573
|
+
}
|
|
502
574
|
|
|
503
575
|
api.logger.info(`Posted follow-up response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
|
|
504
576
|
} catch (err) {
|
|
@@ -553,6 +625,14 @@ export async function handleLinearWebhook(
|
|
|
553
625
|
}
|
|
554
626
|
} catch { /* proceed if viewerId check fails */ }
|
|
555
627
|
|
|
628
|
+
// Early guard: skip if an agent run is already active for this issue.
|
|
629
|
+
// Avoids wasted LLM intent classification (~2-5s) when result would
|
|
630
|
+
// be discarded anyway by activeRuns check in dispatchCommentToAgent().
|
|
631
|
+
if (activeRuns.has(issue.id)) {
|
|
632
|
+
api.logger.info(`Comment on ${issue.identifier ?? issue.id}: active run — skipping`);
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
|
|
556
636
|
// Load agent profiles
|
|
557
637
|
const profiles = loadAgentProfiles();
|
|
558
638
|
const agentNames = Object.keys(profiles);
|
|
@@ -647,7 +727,7 @@ export async function handleLinearWebhook(
|
|
|
647
727
|
void (async () => {
|
|
648
728
|
try {
|
|
649
729
|
await endPlanningSession(planSession.projectId, "approved", planStatePath);
|
|
650
|
-
await linearApi
|
|
730
|
+
await createCommentWithDedup(linearApi,
|
|
651
731
|
planSession.rootIssueId,
|
|
652
732
|
`## Plan Approved\n\nPlan for **${planSession.projectName}** has been approved. Dispatching to workers.`,
|
|
653
733
|
);
|
|
@@ -680,7 +760,7 @@ export async function handleLinearWebhook(
|
|
|
680
760
|
void (async () => {
|
|
681
761
|
try {
|
|
682
762
|
await endPlanningSession(planSession.projectId, "abandoned", planStatePath);
|
|
683
|
-
await linearApi
|
|
763
|
+
await createCommentWithDedup(linearApi,
|
|
684
764
|
planSession.rootIssueId,
|
|
685
765
|
`Planning mode ended for **${planSession.projectName}**. Session abandoned.`,
|
|
686
766
|
);
|
|
@@ -740,6 +820,16 @@ export async function handleLinearWebhook(
|
|
|
740
820
|
res.end("ok");
|
|
741
821
|
|
|
742
822
|
const issue = payload.data;
|
|
823
|
+
|
|
824
|
+
// Guard: check activeRuns FIRST (synchronous, O(1)) before any async work.
|
|
825
|
+
// Linear can send duplicate Issue.update webhooks <20ms apart for the same
|
|
826
|
+
// assignment change. Without this sync guard, both pass through the async
|
|
827
|
+
// getViewerId() call before either registers with wasRecentlyProcessed().
|
|
828
|
+
if (activeRuns.has(issue?.id)) {
|
|
829
|
+
api.logger.info(`Issue.update ${issue?.identifier ?? issue?.id}: active run — skipping`);
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
|
|
743
833
|
const updatedFrom = payload.updatedFrom ?? {};
|
|
744
834
|
|
|
745
835
|
// Check both assigneeId and delegateId — Linear uses delegateId for agent delegation
|
|
@@ -777,7 +867,8 @@ export async function handleLinearWebhook(
|
|
|
777
867
|
const trigger = isDelegatedToUs ? "delegated" : "assigned";
|
|
778
868
|
api.logger.info(`Issue ${trigger} to our app user (${viewerId}), executing pipeline`);
|
|
779
869
|
|
|
780
|
-
//
|
|
870
|
+
// Secondary dedup: catch duplicate webhooks that both passed the activeRuns
|
|
871
|
+
// check before either could register (belt-and-suspenders with the sync guard).
|
|
781
872
|
const dedupKey = `${trigger}:${issue.id}:${viewerId}`;
|
|
782
873
|
if (wasRecentlyProcessed(dedupKey)) {
|
|
783
874
|
api.logger.info(`${trigger} ${issue.id} -> ${viewerId} already processed — skipping`);
|
|
@@ -895,7 +986,7 @@ export async function handleLinearWebhook(
|
|
|
895
986
|
if (agentSessionId) {
|
|
896
987
|
await linearApi.emitActivity(agentSessionId, {
|
|
897
988
|
type: "thought",
|
|
898
|
-
body:
|
|
989
|
+
body: `${label} is triaging new issue ${enrichedIssue?.identifier ?? issue.id}...`,
|
|
899
990
|
}).catch(() => {});
|
|
900
991
|
}
|
|
901
992
|
|
|
@@ -951,6 +1042,10 @@ export async function handleLinearWebhook(
|
|
|
951
1042
|
message,
|
|
952
1043
|
timeoutMs: 3 * 60_000,
|
|
953
1044
|
streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
|
|
1045
|
+
// Triage is strictly read-only: the agent can read/search the
|
|
1046
|
+
// codebase but all write-capable tools are denied via config
|
|
1047
|
+
// policy. The only artifacts are a Linear comment + issue updates.
|
|
1048
|
+
readOnly: true,
|
|
954
1049
|
});
|
|
955
1050
|
|
|
956
1051
|
const responseBody = result.success
|
|
@@ -999,30 +1094,26 @@ export async function handleLinearWebhook(
|
|
|
999
1094
|
}
|
|
1000
1095
|
}
|
|
1001
1096
|
|
|
1002
|
-
//
|
|
1003
|
-
|
|
1004
|
-
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1005
|
-
: undefined;
|
|
1006
|
-
|
|
1007
|
-
try {
|
|
1008
|
-
if (brandingOpts) {
|
|
1009
|
-
await linearApi.createComment(issue.id, commentBody, brandingOpts);
|
|
1010
|
-
} else {
|
|
1011
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
|
|
1012
|
-
}
|
|
1013
|
-
} catch (brandErr) {
|
|
1014
|
-
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
1015
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1097
|
+
// When a session exists, prefer emitActivity (avoids duplicate comment).
|
|
1098
|
+
// Otherwise, post as a regular comment.
|
|
1018
1099
|
if (agentSessionId) {
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
: commentBody;
|
|
1022
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
1100
|
+
const labeledComment = `**[${label}]** ${commentBody}`;
|
|
1101
|
+
const emitted = await linearApi.emitActivity(agentSessionId, {
|
|
1023
1102
|
type: "response",
|
|
1024
|
-
body:
|
|
1025
|
-
}).catch(() =>
|
|
1103
|
+
body: labeledComment,
|
|
1104
|
+
}).then(() => true).catch(() => false);
|
|
1105
|
+
|
|
1106
|
+
if (!emitted) {
|
|
1107
|
+
const agentOpts = avatarUrl
|
|
1108
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1109
|
+
: undefined;
|
|
1110
|
+
await postAgentComment(api, linearApi, issue.id, commentBody, label, agentOpts);
|
|
1111
|
+
}
|
|
1112
|
+
} else {
|
|
1113
|
+
const agentOpts = avatarUrl
|
|
1114
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1115
|
+
: undefined;
|
|
1116
|
+
await postAgentComment(api, linearApi, issue.id, commentBody, label, agentOpts);
|
|
1026
1117
|
}
|
|
1027
1118
|
|
|
1028
1119
|
api.logger.info(`Triage complete for ${enrichedIssue?.identifier ?? issue.id}`);
|
|
@@ -1136,7 +1227,7 @@ async function dispatchCommentToAgent(
|
|
|
1136
1227
|
if (agentSessionId) {
|
|
1137
1228
|
await linearApi.emitActivity(agentSessionId, {
|
|
1138
1229
|
type: "thought",
|
|
1139
|
-
body:
|
|
1230
|
+
body: `${label} is processing comment on ${issueRef}...`,
|
|
1140
1231
|
}).catch(() => {});
|
|
1141
1232
|
}
|
|
1142
1233
|
|
|
@@ -1156,31 +1247,26 @@ async function dispatchCommentToAgent(
|
|
|
1156
1247
|
? result.output
|
|
1157
1248
|
: `Something went wrong while processing this. The system will retry automatically if possible.`;
|
|
1158
1249
|
|
|
1159
|
-
//
|
|
1160
|
-
|
|
1161
|
-
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1162
|
-
: undefined;
|
|
1163
|
-
|
|
1164
|
-
try {
|
|
1165
|
-
if (brandingOpts) {
|
|
1166
|
-
await linearApi.createComment(issue.id, responseBody, brandingOpts);
|
|
1167
|
-
} else {
|
|
1168
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
1169
|
-
}
|
|
1170
|
-
} catch (brandErr) {
|
|
1171
|
-
api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
|
|
1172
|
-
await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
// Emit response (closes session)
|
|
1250
|
+
// When a session exists, prefer emitActivity (avoids duplicate comment).
|
|
1251
|
+
// Otherwise, post as a regular comment.
|
|
1176
1252
|
if (agentSessionId) {
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1179
|
-
: responseBody;
|
|
1180
|
-
await linearApi.emitActivity(agentSessionId, {
|
|
1253
|
+
const labeledResponse = `**[${label}]** ${responseBody}`;
|
|
1254
|
+
const emitted = await linearApi.emitActivity(agentSessionId, {
|
|
1181
1255
|
type: "response",
|
|
1182
|
-
body:
|
|
1183
|
-
}).catch(() =>
|
|
1256
|
+
body: labeledResponse,
|
|
1257
|
+
}).then(() => true).catch(() => false);
|
|
1258
|
+
|
|
1259
|
+
if (!emitted) {
|
|
1260
|
+
const agentOpts = avatarUrl
|
|
1261
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1262
|
+
: undefined;
|
|
1263
|
+
await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
|
|
1264
|
+
}
|
|
1265
|
+
} else {
|
|
1266
|
+
const agentOpts = avatarUrl
|
|
1267
|
+
? { createAsUser: label, displayIconUrl: avatarUrl }
|
|
1268
|
+
: undefined;
|
|
1269
|
+
await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
|
|
1184
1270
|
}
|
|
1185
1271
|
|
|
1186
1272
|
api.logger.info(`Posted ${agentId} response to ${issueRef}`);
|
|
@@ -1226,7 +1312,7 @@ async function handleDispatch(
|
|
|
1226
1312
|
const planState = await readPlanningState(planStatePath);
|
|
1227
1313
|
if (isInPlanningMode(planState, planProjectId)) {
|
|
1228
1314
|
api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
|
|
1229
|
-
await linearApi
|
|
1315
|
+
await createCommentWithDedup(linearApi,
|
|
1230
1316
|
issue.id,
|
|
1231
1317
|
`**Can't dispatch yet** — this project is in planning mode.\n\n**To continue:** Comment on the planning issue with your requirements, then say **"finalize plan"** when ready.\n\n**To cancel planning:** Comment **"abandon"** on the planning issue.`,
|
|
1232
1318
|
);
|
|
@@ -1249,7 +1335,7 @@ async function handleDispatch(
|
|
|
1249
1335
|
if (!isStale && inMemory) {
|
|
1250
1336
|
// Truly still running in this gateway process
|
|
1251
1337
|
api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
|
|
1252
|
-
await linearApi
|
|
1338
|
+
await createCommentWithDedup(linearApi,
|
|
1253
1339
|
issue.id,
|
|
1254
1340
|
`**Already running** as **${existing.tier}** — status: **${existing.status}**, started ${Math.round(ageMs / 60_000)}m ago.\n\nWorktree: \`${existing.worktreePath}\`\n\n**Options:**\n- Check progress: \`/dispatch status ${identifier}\`\n- Force restart: \`/dispatch retry ${identifier}\` (only works when stuck)\n- Escalate: \`/dispatch escalate ${identifier} "reason"\``,
|
|
1255
1341
|
);
|
|
@@ -1321,7 +1407,7 @@ async function handleDispatch(
|
|
|
1321
1407
|
}
|
|
1322
1408
|
} catch (err) {
|
|
1323
1409
|
api.logger.error(`@dispatch: worktree creation failed: ${err}`);
|
|
1324
|
-
await linearApi
|
|
1410
|
+
await createCommentWithDedup(linearApi,
|
|
1325
1411
|
issue.id,
|
|
1326
1412
|
`**Dispatch failed** — couldn't create the worktree.\n\n> ${String(err).slice(0, 200)}\n\n**What to try:**\n- Check that the base repo exists\n- Re-assign this issue to try again\n- Check logs: \`journalctl --user -u openclaw-gateway --since "5 min ago"\``,
|
|
1327
1413
|
);
|
|
@@ -1425,7 +1511,7 @@ async function handleDispatch(
|
|
|
1425
1511
|
`- All dispatches: \`/dispatch list\``,
|
|
1426
1512
|
].join("\n");
|
|
1427
1513
|
|
|
1428
|
-
await linearApi
|
|
1514
|
+
await createCommentWithDedup(linearApi, issue.id, statusComment);
|
|
1429
1515
|
|
|
1430
1516
|
if (agentSessionId) {
|
|
1431
1517
|
await linearApi.emitActivity(agentSessionId, {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools.test.ts — Integration tests for tool registration.
|
|
3
|
+
*
|
|
4
|
+
* Verifies createLinearTools() returns expected tools and handles
|
|
5
|
+
* configuration flags and graceful failure scenarios.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
vi.mock("./code-tool.js", () => ({
|
|
10
|
+
createCodeTool: vi.fn(() => ({ name: "code_run", execute: vi.fn() })),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("./orchestration-tools.js", () => ({
|
|
14
|
+
createOrchestrationTools: vi.fn(() => [
|
|
15
|
+
{ name: "spawn_agent", execute: vi.fn() },
|
|
16
|
+
{ name: "ask_agent", execute: vi.fn() },
|
|
17
|
+
]),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { createLinearTools } from "./tools.js";
|
|
21
|
+
import { createCodeTool } from "./code-tool.js";
|
|
22
|
+
import { createOrchestrationTools } from "./orchestration-tools.js";
|
|
23
|
+
|
|
24
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
function makeApi(pluginConfig?: Record<string, unknown>) {
|
|
27
|
+
return {
|
|
28
|
+
logger: {
|
|
29
|
+
info: vi.fn(),
|
|
30
|
+
warn: vi.fn(),
|
|
31
|
+
error: vi.fn(),
|
|
32
|
+
debug: vi.fn(),
|
|
33
|
+
},
|
|
34
|
+
pluginConfig: pluginConfig ?? {},
|
|
35
|
+
} as any;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Tests ──────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe("createLinearTools", () => {
|
|
41
|
+
it("returns code_run, spawn_agent, and ask_agent tools", () => {
|
|
42
|
+
const api = makeApi();
|
|
43
|
+
const tools = createLinearTools(api, {});
|
|
44
|
+
|
|
45
|
+
expect(tools).toHaveLength(3);
|
|
46
|
+
const names = tools.map((t: any) => t.name);
|
|
47
|
+
expect(names).toContain("code_run");
|
|
48
|
+
expect(names).toContain("spawn_agent");
|
|
49
|
+
expect(names).toContain("ask_agent");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("includes orchestration tools by default", () => {
|
|
53
|
+
const api = makeApi();
|
|
54
|
+
createLinearTools(api, {});
|
|
55
|
+
|
|
56
|
+
expect(createOrchestrationTools).toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("excludes orchestration tools when enableOrchestration is false", () => {
|
|
60
|
+
vi.mocked(createOrchestrationTools).mockClear();
|
|
61
|
+
const api = makeApi({ enableOrchestration: false });
|
|
62
|
+
const tools = createLinearTools(api, {});
|
|
63
|
+
|
|
64
|
+
expect(tools).toHaveLength(1);
|
|
65
|
+
expect(tools[0].name).toBe("code_run");
|
|
66
|
+
expect(createOrchestrationTools).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("handles code_run creation failure gracefully", () => {
|
|
70
|
+
vi.mocked(createCodeTool).mockImplementationOnce(() => {
|
|
71
|
+
throw new Error("CLI not found");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const api = makeApi();
|
|
75
|
+
const tools = createLinearTools(api, {});
|
|
76
|
+
|
|
77
|
+
expect(tools).toHaveLength(2);
|
|
78
|
+
const names = tools.map((t: any) => t.name);
|
|
79
|
+
expect(names).toContain("spawn_agent");
|
|
80
|
+
expect(names).toContain("ask_agent");
|
|
81
|
+
expect(api.logger.warn).toHaveBeenCalledWith(
|
|
82
|
+
expect.stringContaining("code_run tool not available"),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("handles orchestration tools creation failure gracefully", () => {
|
|
87
|
+
vi.mocked(createOrchestrationTools).mockImplementationOnce(() => {
|
|
88
|
+
throw new Error("orchestration init failed");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const api = makeApi();
|
|
92
|
+
const tools = createLinearTools(api, {});
|
|
93
|
+
|
|
94
|
+
expect(tools).toHaveLength(1);
|
|
95
|
+
expect(tools[0].name).toBe("code_run");
|
|
96
|
+
expect(api.logger.warn).toHaveBeenCalledWith(
|
|
97
|
+
expect.stringContaining("Orchestration tools not available"),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|