@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
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webhook-scenarios.test.ts — Full handler flow tests using captured payloads.
|
|
3
|
+
*
|
|
4
|
+
* Replays webhook payloads through handleLinearWebhook with mocked API
|
|
5
|
+
* dependencies. Tests the complete async handler behavior for each event
|
|
6
|
+
* type: API calls made, session lifecycle, agent runs, and responses.
|
|
7
|
+
*
|
|
8
|
+
* Unlike webhook-dedup.test.ts (dedup logic) and webhook.test.ts (HTTP basics),
|
|
9
|
+
* these tests verify the full business logic paths end-to-end.
|
|
10
|
+
*
|
|
11
|
+
* Key pattern: handlers prefer emitActivity(response) over createComment
|
|
12
|
+
* when an agent session exists — createComment is only used as a fallback
|
|
13
|
+
* when the session activity emission fails.
|
|
14
|
+
*/
|
|
15
|
+
import type { AddressInfo } from "node:net";
|
|
16
|
+
import { createServer } from "node:http";
|
|
17
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
|
|
20
|
+
// ── Hoisted mock references ──────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
mockRunAgent,
|
|
24
|
+
mockGetViewerId,
|
|
25
|
+
mockGetIssueDetails,
|
|
26
|
+
mockCreateComment,
|
|
27
|
+
mockEmitActivity,
|
|
28
|
+
mockUpdateSession,
|
|
29
|
+
mockUpdateIssue,
|
|
30
|
+
mockGetTeamLabels,
|
|
31
|
+
mockCreateSessionOnIssue,
|
|
32
|
+
mockClassifyIntent,
|
|
33
|
+
mockSpawnWorker,
|
|
34
|
+
mockSetActiveSession,
|
|
35
|
+
mockClearActiveSession,
|
|
36
|
+
mockEmitDiagnostic,
|
|
37
|
+
} = vi.hoisted(() => ({
|
|
38
|
+
mockRunAgent: vi.fn(),
|
|
39
|
+
mockGetViewerId: vi.fn(),
|
|
40
|
+
mockGetIssueDetails: vi.fn(),
|
|
41
|
+
mockCreateComment: vi.fn(),
|
|
42
|
+
mockEmitActivity: vi.fn(),
|
|
43
|
+
mockUpdateSession: vi.fn(),
|
|
44
|
+
mockUpdateIssue: vi.fn(),
|
|
45
|
+
mockGetTeamLabels: vi.fn(),
|
|
46
|
+
mockCreateSessionOnIssue: vi.fn(),
|
|
47
|
+
mockClassifyIntent: vi.fn(),
|
|
48
|
+
mockSpawnWorker: vi.fn(),
|
|
49
|
+
mockSetActiveSession: vi.fn(),
|
|
50
|
+
mockClearActiveSession: vi.fn(),
|
|
51
|
+
mockEmitDiagnostic: vi.fn(),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
// ── Module mocks (must precede all imports of tested code) ───────
|
|
55
|
+
|
|
56
|
+
vi.mock("../agent/agent.js", () => ({
|
|
57
|
+
runAgent: mockRunAgent,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock("../api/linear-api.js", () => ({
|
|
61
|
+
LinearAgentApi: class MockLinearAgentApi {
|
|
62
|
+
emitActivity = mockEmitActivity;
|
|
63
|
+
createComment = mockCreateComment;
|
|
64
|
+
getIssueDetails = mockGetIssueDetails;
|
|
65
|
+
updateSession = mockUpdateSession;
|
|
66
|
+
getViewerId = mockGetViewerId;
|
|
67
|
+
updateIssue = mockUpdateIssue;
|
|
68
|
+
getTeamLabels = mockGetTeamLabels;
|
|
69
|
+
createSessionOnIssue = mockCreateSessionOnIssue;
|
|
70
|
+
},
|
|
71
|
+
resolveLinearToken: vi.fn().mockReturnValue({
|
|
72
|
+
accessToken: "test-token",
|
|
73
|
+
source: "env",
|
|
74
|
+
}),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
vi.mock("../pipeline/pipeline.js", () => ({
|
|
78
|
+
spawnWorker: mockSpawnWorker,
|
|
79
|
+
runPlannerStage: vi.fn().mockResolvedValue("mock plan"),
|
|
80
|
+
runFullPipeline: vi.fn().mockResolvedValue(undefined),
|
|
81
|
+
resumePipeline: vi.fn().mockResolvedValue(undefined),
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
vi.mock("../pipeline/active-session.js", () => ({
|
|
85
|
+
setActiveSession: mockSetActiveSession,
|
|
86
|
+
clearActiveSession: mockClearActiveSession,
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
vi.mock("../infra/observability.js", () => ({
|
|
90
|
+
emitDiagnostic: mockEmitDiagnostic,
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
vi.mock("../pipeline/intent-classify.js", () => ({
|
|
94
|
+
classifyIntent: mockClassifyIntent,
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
vi.mock("../pipeline/dispatch-state.js", () => ({
|
|
98
|
+
readDispatchState: vi.fn().mockResolvedValue({ dispatches: { active: {}, completed: {} }, sessionMap: {} }),
|
|
99
|
+
getActiveDispatch: vi.fn().mockReturnValue(null),
|
|
100
|
+
registerDispatch: vi.fn().mockResolvedValue(undefined),
|
|
101
|
+
updateDispatchStatus: vi.fn().mockResolvedValue(undefined),
|
|
102
|
+
completeDispatch: vi.fn().mockResolvedValue(undefined),
|
|
103
|
+
removeActiveDispatch: vi.fn().mockResolvedValue(undefined),
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
vi.mock("../infra/notify.js", () => ({
|
|
107
|
+
createNotifierFromConfig: vi.fn(() => vi.fn().mockResolvedValue(undefined)),
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
vi.mock("../pipeline/tier-assess.js", () => ({
|
|
111
|
+
assessTier: vi.fn().mockResolvedValue({ tier: "standard", model: "test-model", reasoning: "mock assessment" }),
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
vi.mock("../infra/codex-worktree.js", () => ({
|
|
115
|
+
createWorktree: vi.fn().mockReturnValue({ path: "/tmp/mock-worktree", branch: "codex/ENG-123", resumed: false }),
|
|
116
|
+
createMultiWorktree: vi.fn(),
|
|
117
|
+
prepareWorkspace: vi.fn().mockReturnValue({ pulled: false, submodulesInitialized: false, errors: [] }),
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
vi.mock("../infra/multi-repo.js", () => ({
|
|
121
|
+
resolveRepos: vi.fn().mockReturnValue({ repos: [] }),
|
|
122
|
+
isMultiRepo: vi.fn().mockReturnValue(false),
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
vi.mock("../pipeline/artifacts.js", () => ({
|
|
126
|
+
ensureClawDir: vi.fn(),
|
|
127
|
+
writeManifest: vi.fn(),
|
|
128
|
+
writeDispatchMemory: vi.fn(),
|
|
129
|
+
resolveOrchestratorWorkspace: vi.fn().mockReturnValue("/mock/workspace"),
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
vi.mock("../pipeline/planning-state.js", () => ({
|
|
133
|
+
readPlanningState: vi.fn().mockResolvedValue({ sessions: {} }),
|
|
134
|
+
isInPlanningMode: vi.fn().mockReturnValue(false),
|
|
135
|
+
getPlanningSession: vi.fn().mockReturnValue(null),
|
|
136
|
+
endPlanningSession: vi.fn().mockResolvedValue(undefined),
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
vi.mock("../pipeline/planner.js", () => ({
|
|
140
|
+
initiatePlanningSession: vi.fn().mockResolvedValue(undefined),
|
|
141
|
+
handlePlannerTurn: vi.fn().mockResolvedValue(undefined),
|
|
142
|
+
runPlanAudit: vi.fn().mockResolvedValue(undefined),
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
vi.mock("../pipeline/dag-dispatch.js", () => ({
|
|
146
|
+
startProjectDispatch: vi.fn().mockResolvedValue(undefined),
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
// ── Imports (after mocks) ────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
import { handleLinearWebhook, _resetForTesting } from "../pipeline/webhook.js";
|
|
152
|
+
import {
|
|
153
|
+
makeAgentSessionEventCreated,
|
|
154
|
+
makeAgentSessionEventPrompted,
|
|
155
|
+
makeCommentCreate,
|
|
156
|
+
makeCommentCreateFromBot,
|
|
157
|
+
makeIssueCreate,
|
|
158
|
+
makeIssueUpdateWithAssignment,
|
|
159
|
+
makeAppUserNotification,
|
|
160
|
+
} from "./fixtures/webhook-payloads.js";
|
|
161
|
+
import { makeIssueDetails } from "./fixtures/linear-responses.js";
|
|
162
|
+
|
|
163
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function createApi(): OpenClawPluginApi {
|
|
166
|
+
return {
|
|
167
|
+
logger: {
|
|
168
|
+
info: vi.fn(),
|
|
169
|
+
warn: vi.fn(),
|
|
170
|
+
error: vi.fn(),
|
|
171
|
+
debug: vi.fn(),
|
|
172
|
+
},
|
|
173
|
+
runtime: {},
|
|
174
|
+
pluginConfig: { defaultAgentId: "mal" },
|
|
175
|
+
} as unknown as OpenClawPluginApi;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function withServer(
|
|
179
|
+
handler: Parameters<typeof createServer>[0],
|
|
180
|
+
fn: (baseUrl: string) => Promise<void>,
|
|
181
|
+
) {
|
|
182
|
+
const server = createServer(handler);
|
|
183
|
+
await new Promise<void>((resolve) => {
|
|
184
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
185
|
+
});
|
|
186
|
+
const address = server.address() as AddressInfo | null;
|
|
187
|
+
if (!address) throw new Error("missing server address");
|
|
188
|
+
try {
|
|
189
|
+
await fn(`http://127.0.0.1:${address.port}`);
|
|
190
|
+
} finally {
|
|
191
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function postWebhook(api: OpenClawPluginApi, payload: unknown) {
|
|
196
|
+
let status = 0;
|
|
197
|
+
let body = "";
|
|
198
|
+
await withServer(
|
|
199
|
+
async (req, res) => {
|
|
200
|
+
await handleLinearWebhook(api, req, res);
|
|
201
|
+
},
|
|
202
|
+
async (baseUrl) => {
|
|
203
|
+
const response = await fetch(`${baseUrl}/linear/webhook`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { "content-type": "application/json" },
|
|
206
|
+
body: JSON.stringify(payload),
|
|
207
|
+
});
|
|
208
|
+
status = response.status;
|
|
209
|
+
body = await response.text();
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
return { status, body };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function infoLogs(api: OpenClawPluginApi): string[] {
|
|
216
|
+
return (api.logger.info as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
217
|
+
(c: unknown[]) => String(c[0]),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function errorLogs(api: OpenClawPluginApi): string[] {
|
|
222
|
+
return (api.logger.error as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
223
|
+
(c: unknown[]) => String(c[0]),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Wait for a mock to be called within a timeout.
|
|
229
|
+
* Used for fire-and-forget `void (async () => {...})()` handlers.
|
|
230
|
+
*/
|
|
231
|
+
async function waitForMock(
|
|
232
|
+
mock: ReturnType<typeof vi.fn>,
|
|
233
|
+
opts?: { timeout?: number; times?: number },
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
const timeout = opts?.timeout ?? 2000;
|
|
236
|
+
const times = opts?.times ?? 1;
|
|
237
|
+
await vi.waitFor(
|
|
238
|
+
() => { expect(mock).toHaveBeenCalledTimes(times); },
|
|
239
|
+
{ timeout, interval: 50 },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Extract emitActivity calls that have a specific type (thought, response, error, action). */
|
|
244
|
+
function activityCallsOfType(type: string): unknown[][] {
|
|
245
|
+
return mockEmitActivity.mock.calls.filter(
|
|
246
|
+
(c: unknown[]) => (c[1] as any)?.type === type,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Setup / Teardown ─────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
beforeEach(() => {
|
|
253
|
+
vi.clearAllMocks();
|
|
254
|
+
_resetForTesting();
|
|
255
|
+
|
|
256
|
+
// Default mock behaviors
|
|
257
|
+
mockGetViewerId.mockResolvedValue("viewer-bot-1");
|
|
258
|
+
mockGetIssueDetails.mockResolvedValue(makeIssueDetails());
|
|
259
|
+
mockCreateComment.mockResolvedValue("comment-new-id");
|
|
260
|
+
mockEmitActivity.mockResolvedValue(undefined);
|
|
261
|
+
mockUpdateSession.mockResolvedValue(undefined);
|
|
262
|
+
mockUpdateIssue.mockResolvedValue(true);
|
|
263
|
+
mockCreateSessionOnIssue.mockResolvedValue({ sessionId: "session-mock-1" });
|
|
264
|
+
mockGetTeamLabels.mockResolvedValue([
|
|
265
|
+
{ id: "label-bug", name: "Bug" },
|
|
266
|
+
{ id: "label-feature", name: "Feature" },
|
|
267
|
+
]);
|
|
268
|
+
mockRunAgent.mockResolvedValue({ success: true, output: "Agent response text" });
|
|
269
|
+
mockSpawnWorker.mockResolvedValue(undefined);
|
|
270
|
+
mockClassifyIntent.mockResolvedValue({
|
|
271
|
+
intent: "general",
|
|
272
|
+
reasoning: "test fallback",
|
|
273
|
+
fromFallback: true,
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
afterEach(() => {
|
|
278
|
+
_resetForTesting();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── Tests ────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
describe("webhook scenario tests — full handler flows", () => {
|
|
284
|
+
describe("AgentSessionEvent", () => {
|
|
285
|
+
it("created: runs agent, delivers response via emitActivity", async () => {
|
|
286
|
+
const api = createApi();
|
|
287
|
+
const payload = makeAgentSessionEventCreated();
|
|
288
|
+
const result = await postWebhook(api, payload);
|
|
289
|
+
expect(result.status).toBe(200);
|
|
290
|
+
|
|
291
|
+
// Wait for the fire-and-forget handler to complete
|
|
292
|
+
await waitForMock(mockClearActiveSession);
|
|
293
|
+
|
|
294
|
+
// Issue enrichment
|
|
295
|
+
expect(mockGetIssueDetails).toHaveBeenCalledWith("issue-1");
|
|
296
|
+
|
|
297
|
+
// Agent invoked with correct session/message
|
|
298
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
299
|
+
const runArgs = mockRunAgent.mock.calls[0][0];
|
|
300
|
+
expect(runArgs.sessionId).toContain("linear-session-sess-event-1");
|
|
301
|
+
expect(runArgs.message).toContain("ENG-123");
|
|
302
|
+
|
|
303
|
+
// emitActivity called with thought and response
|
|
304
|
+
expect(activityCallsOfType("thought").length).toBeGreaterThan(0);
|
|
305
|
+
expect(activityCallsOfType("response").length).toBeGreaterThan(0);
|
|
306
|
+
|
|
307
|
+
// Response delivered via emitActivity (session-first pattern),
|
|
308
|
+
// NOT via createComment — avoids duplicate visible messages.
|
|
309
|
+
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
310
|
+
|
|
311
|
+
// Session lifecycle
|
|
312
|
+
expect(mockSetActiveSession).toHaveBeenCalledWith(
|
|
313
|
+
expect.objectContaining({ issueId: "issue-1", agentSessionId: "sess-event-1" }),
|
|
314
|
+
);
|
|
315
|
+
expect(mockClearActiveSession).toHaveBeenCalledWith("issue-1");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("created: falls back to createComment when emitActivity fails", async () => {
|
|
319
|
+
// Make the response emitActivity fail — comment is the fallback
|
|
320
|
+
let emitCallCount = 0;
|
|
321
|
+
mockEmitActivity.mockImplementation(async (_sessionId: string, content: any) => {
|
|
322
|
+
emitCallCount++;
|
|
323
|
+
// Let the "thought" emission succeed, but fail the "response" emission
|
|
324
|
+
if (content?.type === "response") {
|
|
325
|
+
throw new Error("session expired");
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const api = createApi();
|
|
330
|
+
const payload = makeAgentSessionEventCreated();
|
|
331
|
+
await postWebhook(api, payload);
|
|
332
|
+
|
|
333
|
+
await waitForMock(mockClearActiveSession);
|
|
334
|
+
|
|
335
|
+
// runAgent was called
|
|
336
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
337
|
+
|
|
338
|
+
// emitActivity(response) failed → fell back to createComment
|
|
339
|
+
expect(mockCreateComment).toHaveBeenCalledOnce();
|
|
340
|
+
const commentBody = mockCreateComment.mock.calls[0][1] as string;
|
|
341
|
+
expect(commentBody).toContain("Agent response text");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("prompted: processes follow-up, delivers via emitActivity", async () => {
|
|
345
|
+
const api = createApi();
|
|
346
|
+
const payload = makeAgentSessionEventPrompted();
|
|
347
|
+
const result = await postWebhook(api, payload);
|
|
348
|
+
expect(result.status).toBe(200);
|
|
349
|
+
|
|
350
|
+
await waitForMock(mockClearActiveSession);
|
|
351
|
+
|
|
352
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
353
|
+
const msg = mockRunAgent.mock.calls[0][0].message;
|
|
354
|
+
expect(msg).toContain("Follow-up question here");
|
|
355
|
+
|
|
356
|
+
// Response via emitActivity, not createComment
|
|
357
|
+
expect(activityCallsOfType("response").length).toBeGreaterThan(0);
|
|
358
|
+
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
359
|
+
expect(mockClearActiveSession).toHaveBeenCalledWith("issue-1");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("created with missing data: logs error, no crash", async () => {
|
|
363
|
+
const api = createApi();
|
|
364
|
+
const payload = {
|
|
365
|
+
type: "AgentSessionEvent",
|
|
366
|
+
action: "created",
|
|
367
|
+
agentSession: { id: null, issue: null },
|
|
368
|
+
previousComments: [],
|
|
369
|
+
};
|
|
370
|
+
const result = await postWebhook(api, payload);
|
|
371
|
+
expect(result.status).toBe(200);
|
|
372
|
+
|
|
373
|
+
const errors = errorLogs(api);
|
|
374
|
+
expect(errors.some((e) => e.includes("missing session or issue"))).toBe(true);
|
|
375
|
+
expect(mockRunAgent).not.toHaveBeenCalled();
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe("Comment.create", () => {
|
|
380
|
+
it("ask_agent intent: dispatches to named agent", async () => {
|
|
381
|
+
mockClassifyIntent.mockResolvedValue({
|
|
382
|
+
intent: "ask_agent",
|
|
383
|
+
agentId: "mal",
|
|
384
|
+
reasoning: "user asking for work",
|
|
385
|
+
fromFallback: false,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const api = createApi();
|
|
389
|
+
const payload = makeCommentCreate({
|
|
390
|
+
data: {
|
|
391
|
+
id: "comment-intent-1",
|
|
392
|
+
body: "Can someone look at this issue?",
|
|
393
|
+
user: { id: "user-human", name: "Human" },
|
|
394
|
+
issue: {
|
|
395
|
+
id: "issue-intent-1",
|
|
396
|
+
identifier: "ENG-301",
|
|
397
|
+
title: "Intent test",
|
|
398
|
+
team: { id: "team-1" },
|
|
399
|
+
project: null,
|
|
400
|
+
},
|
|
401
|
+
createdAt: new Date().toISOString(),
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
await postWebhook(api, payload);
|
|
405
|
+
|
|
406
|
+
// Wait for the full dispatch to complete
|
|
407
|
+
await waitForMock(mockClearActiveSession);
|
|
408
|
+
|
|
409
|
+
expect(mockClassifyIntent).toHaveBeenCalledOnce();
|
|
410
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
411
|
+
|
|
412
|
+
// Session created → response via emitActivity
|
|
413
|
+
expect(activityCallsOfType("response").length).toBeGreaterThan(0);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("request_work intent: dispatches to default agent", async () => {
|
|
417
|
+
mockClassifyIntent.mockResolvedValue({
|
|
418
|
+
intent: "request_work",
|
|
419
|
+
reasoning: "user wants implementation",
|
|
420
|
+
fromFallback: false,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const api = createApi();
|
|
424
|
+
const payload = makeCommentCreate({
|
|
425
|
+
data: {
|
|
426
|
+
id: "comment-work-1",
|
|
427
|
+
body: "Please implement the login page",
|
|
428
|
+
user: { id: "user-human", name: "Human" },
|
|
429
|
+
issue: {
|
|
430
|
+
id: "issue-work-1",
|
|
431
|
+
identifier: "ENG-350",
|
|
432
|
+
title: "Login implementation",
|
|
433
|
+
team: { id: "team-1" },
|
|
434
|
+
project: null,
|
|
435
|
+
},
|
|
436
|
+
createdAt: new Date().toISOString(),
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
await postWebhook(api, payload);
|
|
440
|
+
|
|
441
|
+
await waitForMock(mockRunAgent);
|
|
442
|
+
|
|
443
|
+
expect(mockClassifyIntent).toHaveBeenCalledOnce();
|
|
444
|
+
|
|
445
|
+
const logs = infoLogs(api);
|
|
446
|
+
expect(logs.some((l) => l.includes("request_work"))).toBe(true);
|
|
447
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("bot's own comment: skips without running agent", async () => {
|
|
451
|
+
const api = createApi();
|
|
452
|
+
const payload = makeCommentCreateFromBot("viewer-bot-1");
|
|
453
|
+
await postWebhook(api, payload);
|
|
454
|
+
// Small wait for the async getViewerId check
|
|
455
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
456
|
+
|
|
457
|
+
const logs = infoLogs(api);
|
|
458
|
+
expect(logs.some((l) => l.includes("skipping our own comment"))).toBe(true);
|
|
459
|
+
expect(mockRunAgent).not.toHaveBeenCalled();
|
|
460
|
+
expect(mockClassifyIntent).not.toHaveBeenCalled();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("general intent: no action taken, no agent run", async () => {
|
|
464
|
+
const api = createApi();
|
|
465
|
+
const payload = makeCommentCreate({
|
|
466
|
+
data: {
|
|
467
|
+
id: "comment-general-1",
|
|
468
|
+
body: "Thanks for the update",
|
|
469
|
+
user: { id: "user-human", name: "Human" },
|
|
470
|
+
issue: {
|
|
471
|
+
id: "issue-general-1",
|
|
472
|
+
identifier: "ENG-302",
|
|
473
|
+
title: "General test",
|
|
474
|
+
team: { id: "team-1" },
|
|
475
|
+
project: null,
|
|
476
|
+
},
|
|
477
|
+
createdAt: new Date().toISOString(),
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
await postWebhook(api, payload);
|
|
481
|
+
await vi.waitFor(
|
|
482
|
+
() => { expect(mockClassifyIntent).toHaveBeenCalledOnce(); },
|
|
483
|
+
{ timeout: 2000, interval: 50 },
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const logs = infoLogs(api);
|
|
487
|
+
expect(logs.some((l) => l.includes("no action taken"))).toBe(true);
|
|
488
|
+
expect(mockRunAgent).not.toHaveBeenCalled();
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
describe("Issue.update", () => {
|
|
493
|
+
it("assignment dispatch: triggers handleDispatch pipeline", async () => {
|
|
494
|
+
// Set viewerId to match the fixture's assigneeId
|
|
495
|
+
mockGetViewerId.mockResolvedValue("viewer-1");
|
|
496
|
+
|
|
497
|
+
const api = createApi();
|
|
498
|
+
const payload = makeIssueUpdateWithAssignment();
|
|
499
|
+
await postWebhook(api, payload);
|
|
500
|
+
|
|
501
|
+
await waitForMock(mockSpawnWorker, { timeout: 3000 });
|
|
502
|
+
expect(mockSpawnWorker).toHaveBeenCalledOnce();
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
describe("Issue.create", () => {
|
|
507
|
+
it("auto-triage: applies estimate, labels, priority from agent output", async () => {
|
|
508
|
+
// Mock getIssueDetails to return issue matching the payload
|
|
509
|
+
mockGetIssueDetails.mockResolvedValue(makeIssueDetails({
|
|
510
|
+
id: "issue-new",
|
|
511
|
+
identifier: "ENG-200",
|
|
512
|
+
title: "New issue",
|
|
513
|
+
}));
|
|
514
|
+
|
|
515
|
+
mockRunAgent.mockResolvedValueOnce({
|
|
516
|
+
success: true,
|
|
517
|
+
output:
|
|
518
|
+
'```json\n{"estimate": 3, "labelIds": ["label-bug"], "priority": 3, "assessment": "Medium complexity"}\n```\n\nThis issue needs moderate work.',
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const api = createApi();
|
|
522
|
+
const payload = makeIssueCreate();
|
|
523
|
+
await postWebhook(api, payload);
|
|
524
|
+
|
|
525
|
+
// Wait for the triage handler to complete
|
|
526
|
+
await waitForMock(mockClearActiveSession);
|
|
527
|
+
|
|
528
|
+
// Issue enrichment + team labels
|
|
529
|
+
expect(mockGetIssueDetails).toHaveBeenCalledWith("issue-new");
|
|
530
|
+
expect(mockGetTeamLabels).toHaveBeenCalled();
|
|
531
|
+
|
|
532
|
+
// Session created for triage
|
|
533
|
+
expect(mockCreateSessionOnIssue).toHaveBeenCalledWith("issue-new");
|
|
534
|
+
|
|
535
|
+
// Agent invoked in read-only mode
|
|
536
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
537
|
+
const runArgs = mockRunAgent.mock.calls[0][0];
|
|
538
|
+
expect(runArgs.readOnly).toBe(true);
|
|
539
|
+
expect(runArgs.message).toContain("ENG-200");
|
|
540
|
+
|
|
541
|
+
// Triage JSON applied to issue
|
|
542
|
+
expect(mockUpdateIssue).toHaveBeenCalledWith(
|
|
543
|
+
"issue-new",
|
|
544
|
+
expect.objectContaining({
|
|
545
|
+
estimate: 3,
|
|
546
|
+
priority: 3,
|
|
547
|
+
}),
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
// Response delivered via emitActivity (session exists)
|
|
551
|
+
expect(activityCallsOfType("response").length).toBeGreaterThan(0);
|
|
552
|
+
expect(mockClearActiveSession).toHaveBeenCalledWith("issue-new");
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
describe("AppUserNotification", () => {
|
|
557
|
+
it("ignored: returns 200 with no API calls", async () => {
|
|
558
|
+
const api = createApi();
|
|
559
|
+
const result = await postWebhook(api, makeAppUserNotification());
|
|
560
|
+
expect(result.status).toBe(200);
|
|
561
|
+
|
|
562
|
+
const logs = infoLogs(api);
|
|
563
|
+
expect(logs.some((l) => l.includes("AppUserNotification ignored"))).toBe(true);
|
|
564
|
+
|
|
565
|
+
expect(mockRunAgent).not.toHaveBeenCalled();
|
|
566
|
+
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
567
|
+
expect(mockGetIssueDetails).not.toHaveBeenCalled();
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
});
|
package/src/agent/agent.ts
CHANGED
|
@@ -68,6 +68,12 @@ export async function runAgent(params: {
|
|
|
68
68
|
message: string;
|
|
69
69
|
timeoutMs?: number;
|
|
70
70
|
streaming?: AgentStreamCallbacks;
|
|
71
|
+
/**
|
|
72
|
+
* Read-only mode: agent keeps read tools (read, glob, grep, web_search,
|
|
73
|
+
* web_fetch) but all write-capable tools are denied via config policy.
|
|
74
|
+
* Subprocess fallback is blocked — only the embedded runner is safe.
|
|
75
|
+
*/
|
|
76
|
+
readOnly?: boolean;
|
|
71
77
|
}): Promise<AgentRunResult> {
|
|
72
78
|
const maxAttempts = 2;
|
|
73
79
|
|
|
@@ -126,8 +132,9 @@ async function runAgentOnce(params: {
|
|
|
126
132
|
message: string;
|
|
127
133
|
timeoutMs?: number;
|
|
128
134
|
streaming?: AgentStreamCallbacks;
|
|
135
|
+
readOnly?: boolean;
|
|
129
136
|
}): Promise<AgentRunResult> {
|
|
130
|
-
const { api, agentId, sessionId, streaming } = params;
|
|
137
|
+
const { api, agentId, sessionId, streaming, readOnly } = params;
|
|
131
138
|
|
|
132
139
|
// Inject current timestamp into every LLM request
|
|
133
140
|
const message = `${buildDateContext()}\n\n${params.message}`;
|
|
@@ -136,24 +143,60 @@ async function runAgentOnce(params: {
|
|
|
136
143
|
const wdConfig = resolveWatchdogConfig(agentId, pluginConfig);
|
|
137
144
|
const timeoutMs = params.timeoutMs ?? wdConfig.maxTotalMs;
|
|
138
145
|
|
|
139
|
-
api.logger.info(`Dispatching agent ${agentId} for session ${sessionId} (timeout=${Math.round(timeoutMs / 1000)}s, inactivity=${Math.round(wdConfig.inactivityMs / 1000)}s)`);
|
|
146
|
+
api.logger.info(`Dispatching agent ${agentId} for session ${sessionId} (timeout=${Math.round(timeoutMs / 1000)}s, inactivity=${Math.round(wdConfig.inactivityMs / 1000)}s${readOnly ? ", mode=READ_ONLY" : ""})`);
|
|
140
147
|
|
|
141
148
|
// Try embedded runner first (has streaming callbacks)
|
|
142
149
|
if (streaming) {
|
|
143
150
|
try {
|
|
144
|
-
return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming, wdConfig.inactivityMs);
|
|
151
|
+
return await runEmbedded(api, agentId, sessionId, message, timeoutMs, streaming, wdConfig.inactivityMs, readOnly);
|
|
145
152
|
} catch (err) {
|
|
153
|
+
// Read-only mode MUST NOT fall back to subprocess — subprocess runs a
|
|
154
|
+
// full agent with no way to enforce the tool deny policy.
|
|
155
|
+
if (readOnly) {
|
|
156
|
+
api.logger.error(`Embedded runner failed in read-only mode, refusing subprocess fallback: ${err}`);
|
|
157
|
+
return { success: false, output: "Read-only agent run failed (embedded runner unavailable)." };
|
|
158
|
+
}
|
|
146
159
|
api.logger.warn(`Embedded runner failed, falling back to subprocess: ${err}`);
|
|
147
160
|
}
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
// Fallback: subprocess (no streaming)
|
|
164
|
+
if (readOnly) {
|
|
165
|
+
api.logger.error("Cannot run read-only agent via subprocess — no tool policy enforcement");
|
|
166
|
+
return { success: false, output: "Read-only agent run requires the embedded runner." };
|
|
167
|
+
}
|
|
151
168
|
return runSubprocess(api, agentId, sessionId, message, timeoutMs);
|
|
152
169
|
}
|
|
153
170
|
|
|
154
171
|
/**
|
|
155
172
|
* Embedded agent runner with real-time streaming to Linear and inactivity watchdog.
|
|
156
173
|
*/
|
|
174
|
+
// Tools denied in read-only mode. Uses OpenClaw group:* shorthands where
|
|
175
|
+
// possible (see https://docs.openclaw.ai/tools). Covers every built-in
|
|
176
|
+
// tool that can mutate the filesystem, execute commands, or produce
|
|
177
|
+
// side-effects beyond the Linear API calls the plugin makes after the run.
|
|
178
|
+
//
|
|
179
|
+
// NOT denied (read-only tools the triage agent keeps):
|
|
180
|
+
// read, glob, grep/search — codebase inspection
|
|
181
|
+
// group:web (web_search, web_fetch) — external context
|
|
182
|
+
// group:memory (memory_search/get) — knowledge retrieval
|
|
183
|
+
// sessions_list, sessions_history — read-only introspection
|
|
184
|
+
const READ_ONLY_DENY: string[] = [
|
|
185
|
+
// group:fs = read + write + edit + apply_patch — but we need read,
|
|
186
|
+
// so deny the write-capable members individually.
|
|
187
|
+
"write", "edit", "apply_patch",
|
|
188
|
+
// Full groups that are entirely write/side-effect oriented:
|
|
189
|
+
"group:runtime", // exec, bash, process
|
|
190
|
+
"group:messaging", // message
|
|
191
|
+
"group:ui", // browser, canvas
|
|
192
|
+
"group:automation", // cron, gateway
|
|
193
|
+
"group:nodes", // nodes
|
|
194
|
+
// Individual tools not covered by a group:
|
|
195
|
+
"sessions_spawn", "sessions_send", // agent orchestration
|
|
196
|
+
"tts", // audio file generation
|
|
197
|
+
"image", // image file generation
|
|
198
|
+
];
|
|
199
|
+
|
|
157
200
|
async function runEmbedded(
|
|
158
201
|
api: OpenClawPluginApi,
|
|
159
202
|
agentId: string,
|
|
@@ -162,12 +205,26 @@ async function runEmbedded(
|
|
|
162
205
|
timeoutMs: number,
|
|
163
206
|
streaming: AgentStreamCallbacks,
|
|
164
207
|
inactivityMs: number,
|
|
208
|
+
readOnly?: boolean,
|
|
165
209
|
): Promise<AgentRunResult> {
|
|
166
210
|
const ext = await getExtensionAPI();
|
|
167
211
|
|
|
168
212
|
// Load config so we can resolve agent dirs and providers correctly.
|
|
169
|
-
|
|
170
|
-
|
|
213
|
+
let config = await api.runtime.config.loadConfig();
|
|
214
|
+
let configAny = config as Record<string, any>;
|
|
215
|
+
|
|
216
|
+
// ── Read-only enforcement ──────────────────────────────────────────
|
|
217
|
+
// Clone the config and inject a tools.deny policy that strips every
|
|
218
|
+
// write-capable tool. The deny list is merged with any existing deny
|
|
219
|
+
// entries so we don't clobber operator-level restrictions.
|
|
220
|
+
if (readOnly) {
|
|
221
|
+
configAny = JSON.parse(JSON.stringify(configAny));
|
|
222
|
+
config = configAny as typeof config;
|
|
223
|
+
if (!configAny.tools) configAny.tools = {};
|
|
224
|
+
const existing: string[] = Array.isArray(configAny.tools.deny) ? configAny.tools.deny : [];
|
|
225
|
+
configAny.tools.deny = [...new Set([...existing, ...READ_ONLY_DENY])];
|
|
226
|
+
api.logger.info(`Read-only mode: tools.deny = [${configAny.tools.deny.join(", ")}]`);
|
|
227
|
+
}
|
|
171
228
|
|
|
172
229
|
// Resolve workspace and agent dirs from config (ext API ignores agentId).
|
|
173
230
|
const dirs = resolveAgentDirs(agentId, configAny);
|
|
@@ -233,6 +290,13 @@ async function runEmbedded(
|
|
|
233
290
|
abortSignal: controller.signal,
|
|
234
291
|
shouldEmitToolResult: () => true,
|
|
235
292
|
shouldEmitToolOutput: () => true,
|
|
293
|
+
...(readOnly ? {
|
|
294
|
+
extraSystemPrompt: [
|
|
295
|
+
"READ-ONLY MODE: You may read and search files but you MUST NOT",
|
|
296
|
+
"write, edit, create, or delete any files. Do not run shell commands.",
|
|
297
|
+
"Your only output is your text response.",
|
|
298
|
+
].join(" "),
|
|
299
|
+
} : {}),
|
|
236
300
|
|
|
237
301
|
// Stream reasoning/thinking to Linear
|
|
238
302
|
onReasoningStream: (payload) => {
|