@calltelemetry/openclaw-linear 0.8.2 → 0.8.4
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 +37 -4
- 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 +631 -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/intent-classify.test.ts +43 -0
- package/src/pipeline/intent-classify.ts +10 -0
- package/src/pipeline/webhook-dedup.test.ts +466 -0
- package/src/pipeline/webhook.ts +372 -112
- package/src/tools/tools.test.ts +100 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webhook-provision.test.ts — Unit tests for webhook auto-provisioning.
|
|
3
|
+
*
|
|
4
|
+
* Tests getWebhookStatus() and provisionWebhook() with inline mock objects.
|
|
5
|
+
* No vi.mock needed — both functions accept linearApi as a parameter.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
getWebhookStatus,
|
|
10
|
+
provisionWebhook,
|
|
11
|
+
REQUIRED_RESOURCE_TYPES,
|
|
12
|
+
WEBHOOK_LABEL,
|
|
13
|
+
} from "./webhook-provision.js";
|
|
14
|
+
|
|
15
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const TEST_URL = "https://example.com/linear/webhook";
|
|
18
|
+
|
|
19
|
+
function makeWebhook(overrides?: Record<string, unknown>) {
|
|
20
|
+
return {
|
|
21
|
+
id: "wh-1",
|
|
22
|
+
label: WEBHOOK_LABEL,
|
|
23
|
+
url: TEST_URL,
|
|
24
|
+
enabled: true,
|
|
25
|
+
resourceTypes: ["Comment", "Issue"],
|
|
26
|
+
allPublicTeams: true,
|
|
27
|
+
team: null,
|
|
28
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeMockApi(overrides?: Record<string, unknown>) {
|
|
34
|
+
return {
|
|
35
|
+
listWebhooks: vi.fn().mockResolvedValue([]),
|
|
36
|
+
createWebhook: vi.fn().mockResolvedValue({ id: "new-wh", enabled: true }),
|
|
37
|
+
updateWebhook: vi.fn().mockResolvedValue(true),
|
|
38
|
+
deleteWebhook: vi.fn().mockResolvedValue(true),
|
|
39
|
+
...overrides,
|
|
40
|
+
} as any;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Tests ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe("REQUIRED_RESOURCE_TYPES", () => {
|
|
46
|
+
it("contains exactly Comment and Issue", () => {
|
|
47
|
+
expect([...REQUIRED_RESOURCE_TYPES]).toEqual(["Comment", "Issue"]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("getWebhookStatus", () => {
|
|
52
|
+
it("returns null when no webhook matches URL", async () => {
|
|
53
|
+
const api = makeMockApi({
|
|
54
|
+
listWebhooks: vi.fn().mockResolvedValue([
|
|
55
|
+
makeWebhook({ url: "https://other.com/webhook" }),
|
|
56
|
+
]),
|
|
57
|
+
});
|
|
58
|
+
const status = await getWebhookStatus(api, TEST_URL);
|
|
59
|
+
expect(status).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns status with no issues when webhook is correctly configured", async () => {
|
|
63
|
+
const api = makeMockApi({
|
|
64
|
+
listWebhooks: vi.fn().mockResolvedValue([makeWebhook()]),
|
|
65
|
+
});
|
|
66
|
+
const status = await getWebhookStatus(api, TEST_URL);
|
|
67
|
+
expect(status).not.toBeNull();
|
|
68
|
+
expect(status!.id).toBe("wh-1");
|
|
69
|
+
expect(status!.issues).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reports disabled webhook", async () => {
|
|
73
|
+
const api = makeMockApi({
|
|
74
|
+
listWebhooks: vi.fn().mockResolvedValue([
|
|
75
|
+
makeWebhook({ enabled: false }),
|
|
76
|
+
]),
|
|
77
|
+
});
|
|
78
|
+
const status = await getWebhookStatus(api, TEST_URL);
|
|
79
|
+
expect(status!.issues).toContain("disabled");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("reports missing event types", async () => {
|
|
83
|
+
const api = makeMockApi({
|
|
84
|
+
listWebhooks: vi.fn().mockResolvedValue([
|
|
85
|
+
makeWebhook({ resourceTypes: ["Comment"] }),
|
|
86
|
+
]),
|
|
87
|
+
});
|
|
88
|
+
const status = await getWebhookStatus(api, TEST_URL);
|
|
89
|
+
expect(status!.issues.some((i) => i.includes("missing event type: Issue"))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reports unnecessary event types", async () => {
|
|
93
|
+
const api = makeMockApi({
|
|
94
|
+
listWebhooks: vi.fn().mockResolvedValue([
|
|
95
|
+
makeWebhook({ resourceTypes: ["Comment", "Issue", "User"] }),
|
|
96
|
+
]),
|
|
97
|
+
});
|
|
98
|
+
const status = await getWebhookStatus(api, TEST_URL);
|
|
99
|
+
expect(status!.issues.some((i) => i.includes("unnecessary event types: User"))).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("provisionWebhook", () => {
|
|
104
|
+
it("creates new webhook when none exists", async () => {
|
|
105
|
+
const api = makeMockApi();
|
|
106
|
+
const result = await provisionWebhook(api, TEST_URL);
|
|
107
|
+
|
|
108
|
+
expect(result.action).toBe("created");
|
|
109
|
+
expect(result.webhookId).toBe("new-wh");
|
|
110
|
+
expect(result.changes).toContain("created new webhook");
|
|
111
|
+
expect(api.createWebhook).toHaveBeenCalledWith(
|
|
112
|
+
expect.objectContaining({
|
|
113
|
+
url: TEST_URL,
|
|
114
|
+
resourceTypes: [...REQUIRED_RESOURCE_TYPES],
|
|
115
|
+
enabled: true,
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns already_ok when webhook is correct", async () => {
|
|
121
|
+
const api = makeMockApi({
|
|
122
|
+
listWebhooks: vi.fn().mockResolvedValue([makeWebhook()]),
|
|
123
|
+
});
|
|
124
|
+
const result = await provisionWebhook(api, TEST_URL);
|
|
125
|
+
|
|
126
|
+
expect(result.action).toBe("already_ok");
|
|
127
|
+
expect(result.webhookId).toBe("wh-1");
|
|
128
|
+
expect(api.createWebhook).not.toHaveBeenCalled();
|
|
129
|
+
expect(api.updateWebhook).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("updates webhook to fix issues", async () => {
|
|
133
|
+
const api = makeMockApi({
|
|
134
|
+
listWebhooks: vi.fn().mockResolvedValue([
|
|
135
|
+
makeWebhook({
|
|
136
|
+
enabled: false,
|
|
137
|
+
resourceTypes: ["Comment", "Issue", "User", "Customer"],
|
|
138
|
+
}),
|
|
139
|
+
]),
|
|
140
|
+
});
|
|
141
|
+
const result = await provisionWebhook(api, TEST_URL);
|
|
142
|
+
|
|
143
|
+
expect(result.action).toBe("updated");
|
|
144
|
+
expect(result.webhookId).toBe("wh-1");
|
|
145
|
+
expect(result.changes).toBeDefined();
|
|
146
|
+
expect(result.changes!.some((c) => c.includes("enabled"))).toBe(true);
|
|
147
|
+
expect(result.changes!.some((c) => c.includes("removed event types"))).toBe(true);
|
|
148
|
+
expect(api.updateWebhook).toHaveBeenCalledWith("wh-1", expect.objectContaining({
|
|
149
|
+
enabled: true,
|
|
150
|
+
resourceTypes: [...REQUIRED_RESOURCE_TYPES],
|
|
151
|
+
}));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("passes teamId option to createWebhook", async () => {
|
|
155
|
+
const api = makeMockApi();
|
|
156
|
+
await provisionWebhook(api, TEST_URL, { teamId: "team-1" });
|
|
157
|
+
|
|
158
|
+
expect(api.createWebhook).toHaveBeenCalledWith(
|
|
159
|
+
expect.objectContaining({ teamId: "team-1" }),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webhook-provision.ts — Auto-provision and validate Linear webhooks.
|
|
3
|
+
*
|
|
4
|
+
* Ensures the workspace webhook exists with the correct URL, event types,
|
|
5
|
+
* and enabled state. Can be run during onboarding, from the CLI, or as
|
|
6
|
+
* part of the doctor checks.
|
|
7
|
+
*
|
|
8
|
+
* Required event types:
|
|
9
|
+
* - "Comment" — user @mentions, follow-ups, feedback
|
|
10
|
+
* - "Issue" — assignment, state changes, triage
|
|
11
|
+
*
|
|
12
|
+
* Excluded (noise):
|
|
13
|
+
* - "User", "Customer", "CustomerNeed" — never handled, generate log noise
|
|
14
|
+
*/
|
|
15
|
+
import { LinearAgentApi } from "../api/linear-api.js";
|
|
16
|
+
|
|
17
|
+
// The exact set of resource types our webhook handler processes.
|
|
18
|
+
export const REQUIRED_RESOURCE_TYPES = ["Comment", "Issue"] as const;
|
|
19
|
+
|
|
20
|
+
export const WEBHOOK_LABEL = "OpenClaw Integration";
|
|
21
|
+
|
|
22
|
+
export interface WebhookStatus {
|
|
23
|
+
id: string;
|
|
24
|
+
url: string;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
resourceTypes: string[];
|
|
27
|
+
label: string | null;
|
|
28
|
+
issues: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ProvisionResult {
|
|
32
|
+
action: "created" | "updated" | "already_ok";
|
|
33
|
+
webhookId: string;
|
|
34
|
+
changes?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Inspect all webhooks and find the one(s) matching our URL pattern.
|
|
39
|
+
*/
|
|
40
|
+
export async function getWebhookStatus(
|
|
41
|
+
linearApi: LinearAgentApi,
|
|
42
|
+
webhookUrl: string,
|
|
43
|
+
): Promise<WebhookStatus | null> {
|
|
44
|
+
const webhooks = await linearApi.listWebhooks();
|
|
45
|
+
const ours = webhooks.find((w) => w.url === webhookUrl);
|
|
46
|
+
if (!ours) return null;
|
|
47
|
+
|
|
48
|
+
const issues: string[] = [];
|
|
49
|
+
if (!ours.enabled) issues.push("disabled");
|
|
50
|
+
|
|
51
|
+
const currentTypes = new Set(ours.resourceTypes);
|
|
52
|
+
const requiredTypes = new Set<string>(REQUIRED_RESOURCE_TYPES);
|
|
53
|
+
|
|
54
|
+
for (const t of requiredTypes) {
|
|
55
|
+
if (!currentTypes.has(t)) issues.push(`missing event type: ${t}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const noiseTypes = [...currentTypes].filter((t) => !requiredTypes.has(t));
|
|
59
|
+
if (noiseTypes.length > 0) {
|
|
60
|
+
issues.push(`unnecessary event types: ${noiseTypes.join(", ")}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
id: ours.id,
|
|
65
|
+
url: ours.url,
|
|
66
|
+
enabled: ours.enabled,
|
|
67
|
+
resourceTypes: ours.resourceTypes,
|
|
68
|
+
label: ours.label,
|
|
69
|
+
issues,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Provision (create or fix) the workspace webhook.
|
|
75
|
+
*
|
|
76
|
+
* - If no webhook with our URL exists → create one
|
|
77
|
+
* - If one exists but has wrong config → update it
|
|
78
|
+
* - If it's already correct → no-op
|
|
79
|
+
*/
|
|
80
|
+
export async function provisionWebhook(
|
|
81
|
+
linearApi: LinearAgentApi,
|
|
82
|
+
webhookUrl: string,
|
|
83
|
+
opts?: { teamId?: string; allPublicTeams?: boolean },
|
|
84
|
+
): Promise<ProvisionResult> {
|
|
85
|
+
const status = await getWebhookStatus(linearApi, webhookUrl);
|
|
86
|
+
|
|
87
|
+
if (!status) {
|
|
88
|
+
// No webhook found — create one
|
|
89
|
+
const result = await linearApi.createWebhook({
|
|
90
|
+
url: webhookUrl,
|
|
91
|
+
resourceTypes: [...REQUIRED_RESOURCE_TYPES],
|
|
92
|
+
label: WEBHOOK_LABEL,
|
|
93
|
+
enabled: true,
|
|
94
|
+
teamId: opts?.teamId,
|
|
95
|
+
allPublicTeams: opts?.allPublicTeams ?? true,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
action: "created",
|
|
100
|
+
webhookId: result.id,
|
|
101
|
+
changes: ["created new webhook"],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Webhook exists — check if it needs updates
|
|
106
|
+
if (status.issues.length === 0) {
|
|
107
|
+
return { action: "already_ok", webhookId: status.id };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Build update payload
|
|
111
|
+
const update: {
|
|
112
|
+
resourceTypes?: string[];
|
|
113
|
+
enabled?: boolean;
|
|
114
|
+
label?: string;
|
|
115
|
+
} = {};
|
|
116
|
+
const changes: string[] = [];
|
|
117
|
+
|
|
118
|
+
// Fix resource types
|
|
119
|
+
const currentTypes = new Set(status.resourceTypes);
|
|
120
|
+
const requiredTypes = new Set<string>(REQUIRED_RESOURCE_TYPES);
|
|
121
|
+
const typesNeedUpdate =
|
|
122
|
+
[...requiredTypes].some((t) => !currentTypes.has(t)) ||
|
|
123
|
+
[...currentTypes].some((t) => !requiredTypes.has(t));
|
|
124
|
+
|
|
125
|
+
if (typesNeedUpdate) {
|
|
126
|
+
update.resourceTypes = [...REQUIRED_RESOURCE_TYPES];
|
|
127
|
+
const removed = [...currentTypes].filter((t) => !requiredTypes.has(t));
|
|
128
|
+
const added = [...requiredTypes].filter((t) => !currentTypes.has(t));
|
|
129
|
+
if (removed.length) changes.push(`removed event types: ${removed.join(", ")}`);
|
|
130
|
+
if (added.length) changes.push(`added event types: ${added.join(", ")}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Fix enabled state
|
|
134
|
+
if (!status.enabled) {
|
|
135
|
+
update.enabled = true;
|
|
136
|
+
changes.push("enabled webhook");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Fix label if missing
|
|
140
|
+
if (!status.label) {
|
|
141
|
+
update.label = WEBHOOK_LABEL;
|
|
142
|
+
changes.push("set label");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await linearApi.updateWebhook(status.id, update);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
action: "updated",
|
|
149
|
+
webhookId: status.id,
|
|
150
|
+
changes,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -195,6 +195,19 @@ describe("classifyIntent", () => {
|
|
|
195
195
|
const call = runAgentMock.mock.calls[0][0];
|
|
196
196
|
expect(call.message).not.toContain("x".repeat(501));
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
it("parses close_issue intent from LLM response", async () => {
|
|
200
|
+
runAgentMock.mockResolvedValueOnce({
|
|
201
|
+
success: true,
|
|
202
|
+
output: '{"intent":"close_issue","reasoning":"user wants to close the issue"}',
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const result = await classifyIntent(createApi(), createCtx({ commentBody: "close this" }));
|
|
206
|
+
|
|
207
|
+
expect(result.intent).toBe("close_issue");
|
|
208
|
+
expect(result.reasoning).toBe("user wants to close the issue");
|
|
209
|
+
expect(result.fromFallback).toBe(false);
|
|
210
|
+
});
|
|
198
211
|
});
|
|
199
212
|
|
|
200
213
|
// ---------------------------------------------------------------------------
|
|
@@ -281,5 +294,35 @@ describe("regexFallback", () => {
|
|
|
281
294
|
expect(result.intent).toBe("general");
|
|
282
295
|
expect(result.fromFallback).toBe(true);
|
|
283
296
|
});
|
|
297
|
+
|
|
298
|
+
it("detects close_issue for 'close this' pattern", () => {
|
|
299
|
+
const result = regexFallback(createCtx({
|
|
300
|
+
commentBody: "close this issue",
|
|
301
|
+
}));
|
|
302
|
+
expect(result.intent).toBe("close_issue");
|
|
303
|
+
expect(result.fromFallback).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("detects close_issue for 'mark as done' pattern", () => {
|
|
307
|
+
const result = regexFallback(createCtx({
|
|
308
|
+
commentBody: "mark as done",
|
|
309
|
+
}));
|
|
310
|
+
expect(result.intent).toBe("close_issue");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("detects close_issue for 'this is resolved' pattern", () => {
|
|
314
|
+
const result = regexFallback(createCtx({
|
|
315
|
+
commentBody: "this is resolved",
|
|
316
|
+
}));
|
|
317
|
+
expect(result.intent).toBe("close_issue");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("does NOT detect close_issue for ambiguous text", () => {
|
|
321
|
+
const result = regexFallback(createCtx({
|
|
322
|
+
commentBody: "I think this might be resolved soon",
|
|
323
|
+
agentNames: [],
|
|
324
|
+
}));
|
|
325
|
+
expect(result.intent).toBe("general");
|
|
326
|
+
});
|
|
284
327
|
});
|
|
285
328
|
});
|
|
@@ -23,6 +23,7 @@ export type Intent =
|
|
|
23
23
|
| "ask_agent"
|
|
24
24
|
| "request_work"
|
|
25
25
|
| "question"
|
|
26
|
+
| "close_issue"
|
|
26
27
|
| "general";
|
|
27
28
|
|
|
28
29
|
export interface IntentResult {
|
|
@@ -55,6 +56,7 @@ const VALID_INTENTS: Set<string> = new Set([
|
|
|
55
56
|
"ask_agent",
|
|
56
57
|
"request_work",
|
|
57
58
|
"question",
|
|
59
|
+
"close_issue",
|
|
58
60
|
"general",
|
|
59
61
|
]);
|
|
60
62
|
|
|
@@ -72,12 +74,14 @@ Intents:
|
|
|
72
74
|
- ask_agent: user is addressing a specific agent by name
|
|
73
75
|
- request_work: user wants something built, fixed, or implemented
|
|
74
76
|
- question: user asking for information or help
|
|
77
|
+
- close_issue: user wants to close/complete/resolve the issue (e.g. "close this", "mark as done", "resolved")
|
|
75
78
|
- general: none of the above, automated messages, or noise
|
|
76
79
|
|
|
77
80
|
Rules:
|
|
78
81
|
- plan_start ONLY if the issue belongs to a project (hasProject=true)
|
|
79
82
|
- If planning mode is active and no clear finalize/abandon intent, default to plan_continue
|
|
80
83
|
- For ask_agent, set agentId to the matching name from Available agents
|
|
84
|
+
- close_issue only for explicit closure requests, NOT ambiguous comments about resolution
|
|
81
85
|
- One sentence reasoning`;
|
|
82
86
|
|
|
83
87
|
// ---------------------------------------------------------------------------
|
|
@@ -188,6 +192,7 @@ function parseIntentResponse(raw: string, ctx: IntentContext): IntentResult | nu
|
|
|
188
192
|
const PLAN_START_PATTERN = /\b(plan|planning)\s+(this\s+)(project|out)\b|\bplan\s+this\s+out\b/i;
|
|
189
193
|
const FINALIZE_PATTERN = /\b(finalize\s+(the\s+)?plan\b|done\s+planning\b(?!\s+\w)|approve\s+(the\s+)?plan\b|plan\s+looks\s+good\b|ready\s+to\s+finalize\b|let'?s\s+finalize\b)/i;
|
|
190
194
|
const ABANDON_PATTERN = /\b(abandon\s+plan(ning)?|cancel\s+plan(ning)?|stop\s+planning|exit\s+planning|quit\s+planning)\b/i;
|
|
195
|
+
const CLOSE_ISSUE_PATTERN = /\b(close\s+(this|the\s+issue)|mark\s+(as\s+)?(done|completed?|resolved)|this\s+is\s+(done|resolved|completed?)|resolve\s+(this|the\s+issue))\b/i;
|
|
191
196
|
|
|
192
197
|
export function regexFallback(ctx: IntentContext): IntentResult {
|
|
193
198
|
const text = ctx.commentBody;
|
|
@@ -209,6 +214,11 @@ export function regexFallback(ctx: IntentContext): IntentResult {
|
|
|
209
214
|
return { intent: "plan_start", reasoning: "regex: plan start pattern matched", fromFallback: true };
|
|
210
215
|
}
|
|
211
216
|
|
|
217
|
+
// Close issue detection
|
|
218
|
+
if (CLOSE_ISSUE_PATTERN.test(text)) {
|
|
219
|
+
return { intent: "close_issue", reasoning: "regex: close issue pattern matched", fromFallback: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
212
222
|
// Agent name detection
|
|
213
223
|
if (ctx.agentNames.length > 0) {
|
|
214
224
|
const lower = text.toLowerCase();
|