@calltelemetry/openclaw-linear 0.7.1 → 0.8.0
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 +719 -539
- package/index.ts +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/prompts.yaml +19 -5
- package/src/__test__/fixtures/linear-responses.ts +75 -0
- package/src/__test__/fixtures/webhook-payloads.ts +113 -0
- package/src/__test__/helpers.ts +133 -0
- package/src/agent/agent.test.ts +143 -0
- package/src/api/linear-api.test.ts +93 -1
- package/src/api/linear-api.ts +37 -1
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/infra/cli.ts +176 -1
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +28 -23
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +29 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +26 -15
- package/src/infra/observability.test.ts +85 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/dispatch-state.ts +1 -0
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +455 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +47 -18
- package/src/pipeline/planner.test.ts +1 -1
- package/src/pipeline/planner.ts +12 -30
- package/src/pipeline/tier-assess.test.ts +89 -0
- package/src/pipeline/webhook.ts +114 -37
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/dispatch-history-tool.test.ts +315 -0
package/src/infra/notify.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
createNoopNotifier,
|
|
4
4
|
createNotifierFromConfig,
|
|
5
5
|
formatMessage,
|
|
6
|
+
formatRichMessage,
|
|
6
7
|
sendToTarget,
|
|
7
8
|
parseNotificationsConfig,
|
|
8
9
|
type NotifyKind,
|
|
@@ -23,24 +24,24 @@ describe("formatMessage", () => {
|
|
|
23
24
|
|
|
24
25
|
it("formats dispatch message", () => {
|
|
25
26
|
const msg = formatMessage("dispatch", basePayload);
|
|
26
|
-
expect(msg).toBe("API-42
|
|
27
|
+
expect(msg).toBe("API-42 started — Fix auth");
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
it("formats working message with attempt", () => {
|
|
30
31
|
const msg = formatMessage("working", { ...basePayload, attempt: 1 });
|
|
31
|
-
expect(msg).toContain("
|
|
32
|
-
expect(msg).toContain("attempt
|
|
32
|
+
expect(msg).toContain("working on it");
|
|
33
|
+
expect(msg).toContain("attempt 2"); // 1-based for humans
|
|
33
34
|
});
|
|
34
35
|
|
|
35
36
|
it("formats auditing message", () => {
|
|
36
37
|
const msg = formatMessage("auditing", basePayload);
|
|
37
|
-
expect(msg).toContain("
|
|
38
|
+
expect(msg).toContain("checking the work");
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
it("formats audit_pass message", () => {
|
|
41
42
|
const msg = formatMessage("audit_pass", basePayload);
|
|
42
|
-
expect(msg).toContain("
|
|
43
|
-
expect(msg).toContain("
|
|
43
|
+
expect(msg).toContain("done!");
|
|
44
|
+
expect(msg).toContain("Ready for review");
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
it("formats audit_fail message with gaps", () => {
|
|
@@ -49,8 +50,8 @@ describe("formatMessage", () => {
|
|
|
49
50
|
attempt: 1,
|
|
50
51
|
verdict: { pass: false, gaps: ["no tests", "missing validation"] },
|
|
51
52
|
});
|
|
52
|
-
expect(msg).toContain("
|
|
53
|
-
expect(msg).toContain("attempt
|
|
53
|
+
expect(msg).toContain("needs more work");
|
|
54
|
+
expect(msg).toContain("attempt 2"); // 1-based for humans
|
|
54
55
|
expect(msg).toContain("no tests");
|
|
55
56
|
expect(msg).toContain("missing validation");
|
|
56
57
|
});
|
|
@@ -67,10 +68,10 @@ describe("formatMessage", () => {
|
|
|
67
68
|
it("formats escalation message with reason", () => {
|
|
68
69
|
const msg = formatMessage("escalation", {
|
|
69
70
|
...basePayload,
|
|
70
|
-
|
|
71
|
+
attempt: 2,
|
|
71
72
|
});
|
|
72
|
-
expect(msg).toContain("needs
|
|
73
|
-
expect(msg).toContain("
|
|
73
|
+
expect(msg).toContain("needs your help");
|
|
74
|
+
expect(msg).toContain("3 tries"); // 1-based
|
|
74
75
|
});
|
|
75
76
|
|
|
76
77
|
it("formats stuck message", () => {
|
|
@@ -86,11 +87,11 @@ describe("formatMessage", () => {
|
|
|
86
87
|
const msg = formatMessage("watchdog_kill", {
|
|
87
88
|
...basePayload,
|
|
88
89
|
attempt: 0,
|
|
89
|
-
reason: "no
|
|
90
|
+
reason: "no activity for 120s",
|
|
90
91
|
});
|
|
91
|
-
expect(msg).toContain("
|
|
92
|
-
expect(msg).toContain("no
|
|
93
|
-
expect(msg).toContain("Retrying (attempt
|
|
92
|
+
expect(msg).toContain("timed out");
|
|
93
|
+
expect(msg).toContain("no activity for 120s");
|
|
94
|
+
expect(msg).toContain("Retrying (attempt 1)"); // 1-based
|
|
94
95
|
});
|
|
95
96
|
|
|
96
97
|
it("formats watchdog_kill without attempt", () => {
|
|
@@ -325,7 +326,7 @@ describe("createNotifierFromConfig", () => {
|
|
|
325
326
|
expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledOnce();
|
|
326
327
|
expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledWith(
|
|
327
328
|
"-100388",
|
|
328
|
-
expect.stringContaining("
|
|
329
|
+
expect.stringContaining("working on it"),
|
|
329
330
|
{ silent: true },
|
|
330
331
|
);
|
|
331
332
|
});
|
|
@@ -402,6 +403,144 @@ describe("createNotifierFromConfig", () => {
|
|
|
402
403
|
});
|
|
403
404
|
});
|
|
404
405
|
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// formatRichMessage
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
describe("formatRichMessage", () => {
|
|
411
|
+
const basePayload: NotifyPayload = {
|
|
412
|
+
identifier: "CT-10",
|
|
413
|
+
title: "Add caching",
|
|
414
|
+
status: "dispatched",
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
it("returns Discord embed with correct color for dispatch (blue)", () => {
|
|
418
|
+
const msg = formatRichMessage("dispatch", basePayload);
|
|
419
|
+
expect(msg.discord?.embeds).toHaveLength(1);
|
|
420
|
+
expect(msg.discord!.embeds[0].color).toBe(0x3498db);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("returns Discord embed with green for audit_pass", () => {
|
|
424
|
+
const msg = formatRichMessage("audit_pass", basePayload);
|
|
425
|
+
expect(msg.discord!.embeds[0].color).toBe(0x2ecc71);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("returns Discord embed with red for audit_fail", () => {
|
|
429
|
+
const msg = formatRichMessage("audit_fail", { ...basePayload, attempt: 1, verdict: { pass: false, gaps: ["no tests"] } });
|
|
430
|
+
expect(msg.discord!.embeds[0].color).toBe(0xe74c3c);
|
|
431
|
+
expect(msg.discord!.embeds[0].fields).toEqual(
|
|
432
|
+
expect.arrayContaining([expect.objectContaining({ name: "Issues to fix", value: "no tests" })]),
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("returns Discord embed with orange for stuck", () => {
|
|
437
|
+
const msg = formatRichMessage("stuck", { ...basePayload, reason: "stale 2h" });
|
|
438
|
+
expect(msg.discord!.embeds[0].color).toBe(0xe67e22);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("returns Telegram HTML with bold identifier", () => {
|
|
442
|
+
const msg = formatRichMessage("dispatch", basePayload);
|
|
443
|
+
expect(msg.telegram?.html).toContain("<b>CT-10</b>");
|
|
444
|
+
expect(msg.telegram?.html).toContain("<i>Add caching</i>");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("includes plain text fallback", () => {
|
|
448
|
+
const msg = formatRichMessage("dispatch", basePayload);
|
|
449
|
+
expect(msg.text).toBe("CT-10 started — Add caching");
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// sendToTarget with RichMessage
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
describe("sendToTarget (RichMessage)", () => {
|
|
458
|
+
function mockRuntime(): any {
|
|
459
|
+
return {
|
|
460
|
+
channel: {
|
|
461
|
+
discord: { sendMessageDiscord: vi.fn(async () => {}) },
|
|
462
|
+
slack: { sendMessageSlack: vi.fn(async () => ({})) },
|
|
463
|
+
telegram: { sendMessageTelegram: vi.fn(async () => {}) },
|
|
464
|
+
signal: { sendMessageSignal: vi.fn(async () => {}) },
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
470
|
+
|
|
471
|
+
it("passes embeds to Discord when RichMessage provided", async () => {
|
|
472
|
+
const runtime = mockRuntime();
|
|
473
|
+
const target: NotifyTarget = { channel: "discord", target: "D-1" };
|
|
474
|
+
const rich = {
|
|
475
|
+
text: "plain",
|
|
476
|
+
discord: { embeds: [{ title: "test", color: 0x3498db }] },
|
|
477
|
+
};
|
|
478
|
+
await sendToTarget(target, rich, runtime);
|
|
479
|
+
expect(runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledWith(
|
|
480
|
+
"D-1", "plain", { embeds: [{ title: "test", color: 0x3498db }] },
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("passes textMode html to Telegram when RichMessage provided", async () => {
|
|
485
|
+
const runtime = mockRuntime();
|
|
486
|
+
const target: NotifyTarget = { channel: "telegram", target: "-999" };
|
|
487
|
+
const rich = {
|
|
488
|
+
text: "plain",
|
|
489
|
+
telegram: { html: "<b>CT-10</b> dispatched" },
|
|
490
|
+
};
|
|
491
|
+
await sendToTarget(target, rich, runtime);
|
|
492
|
+
expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledWith(
|
|
493
|
+
"-999", "<b>CT-10</b> dispatched", { silent: true, textMode: "html" },
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
// createNotifierFromConfig (richFormat)
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
describe("createNotifierFromConfig (richFormat)", () => {
|
|
503
|
+
function mockRuntime(): any {
|
|
504
|
+
return {
|
|
505
|
+
channel: {
|
|
506
|
+
discord: { sendMessageDiscord: vi.fn(async () => {}) },
|
|
507
|
+
slack: { sendMessageSlack: vi.fn(async () => ({})) },
|
|
508
|
+
telegram: { sendMessageTelegram: vi.fn(async () => {}) },
|
|
509
|
+
signal: { sendMessageSignal: vi.fn(async () => {}) },
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
515
|
+
|
|
516
|
+
it("sends Discord embeds when richFormat is true", async () => {
|
|
517
|
+
const runtime = mockRuntime();
|
|
518
|
+
const notify = createNotifierFromConfig({
|
|
519
|
+
notifications: {
|
|
520
|
+
richFormat: true,
|
|
521
|
+
targets: [{ channel: "discord", target: "D-1" }],
|
|
522
|
+
},
|
|
523
|
+
}, runtime);
|
|
524
|
+
await notify("dispatch", { identifier: "CT-1", title: "Test", status: "dispatched" });
|
|
525
|
+
const [, , opts] = runtime.channel.discord.sendMessageDiscord.mock.calls[0];
|
|
526
|
+
expect(opts?.embeds).toBeDefined();
|
|
527
|
+
expect(opts.embeds).toHaveLength(1);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("sends plain text when richFormat is false", async () => {
|
|
531
|
+
const runtime = mockRuntime();
|
|
532
|
+
const notify = createNotifierFromConfig({
|
|
533
|
+
notifications: {
|
|
534
|
+
richFormat: false,
|
|
535
|
+
targets: [{ channel: "discord", target: "D-1" }],
|
|
536
|
+
},
|
|
537
|
+
}, runtime);
|
|
538
|
+
await notify("dispatch", { identifier: "CT-1", title: "Test", status: "dispatched" });
|
|
539
|
+
const call = runtime.channel.discord.sendMessageDiscord.mock.calls[0];
|
|
540
|
+
expect(call).toHaveLength(2); // no third arg with embeds
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
405
544
|
// ---------------------------------------------------------------------------
|
|
406
545
|
// createNoopNotifier
|
|
407
546
|
// ---------------------------------------------------------------------------
|
package/src/infra/notify.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* Modeled on DevClaw's notify.ts pattern — the runtime handles token resolution,
|
|
9
9
|
* formatting differences (markdown vs mrkdwn), and delivery per channel.
|
|
10
10
|
*/
|
|
11
|
-
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
11
|
+
import type { PluginRuntime, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
12
|
+
import { emitDiagnostic } from "./observability.js";
|
|
12
13
|
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Types
|
|
@@ -81,26 +82,27 @@ export interface RichMessage {
|
|
|
81
82
|
|
|
82
83
|
export function formatMessage(kind: NotifyKind, payload: NotifyPayload): string {
|
|
83
84
|
const id = payload.identifier;
|
|
85
|
+
const attempt = (payload.attempt ?? 0) + 1; // 1-based for humans
|
|
84
86
|
switch (kind) {
|
|
85
87
|
case "dispatch":
|
|
86
|
-
return `${id}
|
|
88
|
+
return `${id} started — ${payload.title}`;
|
|
87
89
|
case "working":
|
|
88
|
-
return `${id}
|
|
90
|
+
return `${id} working on it (attempt ${attempt})`;
|
|
89
91
|
case "auditing":
|
|
90
|
-
return `${id}
|
|
92
|
+
return `${id} checking the work...`;
|
|
91
93
|
case "audit_pass":
|
|
92
|
-
return
|
|
94
|
+
return `✅ ${id} done! Ready for review.`;
|
|
93
95
|
case "audit_fail": {
|
|
94
|
-
const
|
|
95
|
-
return `${id}
|
|
96
|
+
const issues = payload.verdict?.gaps?.join(", ") ?? "unspecified";
|
|
97
|
+
return `${id} needs more work (attempt ${attempt}). Issues: ${issues}`;
|
|
96
98
|
}
|
|
97
99
|
case "escalation":
|
|
98
|
-
return `🚨 ${id} needs
|
|
100
|
+
return `🚨 ${id} needs your help — couldn't fix it after ${attempt} ${attempt === 1 ? "try" : "tries"}`;
|
|
99
101
|
case "stuck":
|
|
100
|
-
return `⏰ ${id} stuck — ${payload.reason ?? "
|
|
102
|
+
return `⏰ ${id} stuck — ${payload.reason ?? "inactive for 2h"}`;
|
|
101
103
|
case "watchdog_kill":
|
|
102
|
-
return `⚡ ${id}
|
|
103
|
-
payload.attempt != null ? `Retrying (attempt ${
|
|
104
|
+
return `⚡ ${id} timed out (${payload.reason ?? "no activity for 120s"}). ${
|
|
105
|
+
payload.attempt != null ? `Retrying (attempt ${attempt}).` : "Will retry."
|
|
104
106
|
}`;
|
|
105
107
|
case "project_progress":
|
|
106
108
|
return `📊 ${payload.title} (${id}): ${payload.status}`;
|
|
@@ -134,10 +136,10 @@ export function formatRichMessage(kind: NotifyKind, payload: NotifyPayload): Ric
|
|
|
134
136
|
|
|
135
137
|
// Discord embed
|
|
136
138
|
const fields: DiscordEmbed["fields"] = [];
|
|
137
|
-
if (payload.attempt != null) fields.push({ name: "Attempt", value: String(payload.attempt), inline: true });
|
|
139
|
+
if (payload.attempt != null) fields.push({ name: "Attempt", value: String((payload.attempt ?? 0) + 1), inline: true });
|
|
138
140
|
if (payload.status) fields.push({ name: "Status", value: payload.status, inline: true });
|
|
139
141
|
if (payload.verdict?.gaps?.length) {
|
|
140
|
-
fields.push({ name: "
|
|
142
|
+
fields.push({ name: "Issues to fix", value: payload.verdict.gaps.join("\n").slice(0, 1024) });
|
|
141
143
|
}
|
|
142
144
|
if (payload.reason) fields.push({ name: "Reason", value: payload.reason });
|
|
143
145
|
|
|
@@ -154,10 +156,10 @@ export function formatRichMessage(kind: NotifyKind, payload: NotifyPayload): Ric
|
|
|
154
156
|
`<b>${escapeHtml(payload.identifier)}</b> — ${escapeHtml(kind.replace(/_/g, " "))}`,
|
|
155
157
|
`<i>${escapeHtml(payload.title)}</i>`,
|
|
156
158
|
];
|
|
157
|
-
if (payload.attempt != null) htmlParts.push(`Attempt: <code>${payload.attempt}</code>`);
|
|
159
|
+
if (payload.attempt != null) htmlParts.push(`Attempt: <code>${(payload.attempt ?? 0) + 1}</code>`);
|
|
158
160
|
if (payload.status) htmlParts.push(`Status: <code>${escapeHtml(payload.status)}</code>`);
|
|
159
161
|
if (payload.verdict?.gaps?.length) {
|
|
160
|
-
htmlParts.push(`
|
|
162
|
+
htmlParts.push(`Issues to fix:\n${payload.verdict.gaps.map(g => `• ${escapeHtml(g)}`).join("\n")}`);
|
|
161
163
|
}
|
|
162
164
|
if (payload.reason) htmlParts.push(`Reason: ${escapeHtml(payload.reason)}`);
|
|
163
165
|
|
|
@@ -241,6 +243,7 @@ export function parseNotificationsConfig(
|
|
|
241
243
|
export function createNotifierFromConfig(
|
|
242
244
|
pluginConfig: Record<string, unknown> | undefined,
|
|
243
245
|
runtime: PluginRuntime,
|
|
246
|
+
api?: OpenClawPluginApi,
|
|
244
247
|
): NotifyFn {
|
|
245
248
|
const config = parseNotificationsConfig(pluginConfig);
|
|
246
249
|
|
|
@@ -260,6 +263,14 @@ export function createNotifierFromConfig(
|
|
|
260
263
|
await sendToTarget(target, message, runtime);
|
|
261
264
|
} catch (err) {
|
|
262
265
|
console.error(`Notify error (${target.channel}:${target.target}):`, err);
|
|
266
|
+
if (api) {
|
|
267
|
+
emitDiagnostic(api, {
|
|
268
|
+
event: "notify_failed",
|
|
269
|
+
identifier: payload.identifier,
|
|
270
|
+
phase: kind,
|
|
271
|
+
error: String(err),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
263
274
|
}
|
|
264
275
|
}),
|
|
265
276
|
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { emitDiagnostic, type DiagnosticPayload } from "./observability.ts";
|
|
3
|
+
|
|
4
|
+
function makeApi(infoFn = vi.fn()) {
|
|
5
|
+
return { logger: { info: infoFn } } as any;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("emitDiagnostic", () => {
|
|
9
|
+
it("emits JSON with [linear:diagnostic] prefix via api.logger.info", () => {
|
|
10
|
+
const info = vi.fn();
|
|
11
|
+
const api = makeApi(info);
|
|
12
|
+
emitDiagnostic(api, { event: "webhook_received", identifier: "ISS-42" });
|
|
13
|
+
expect(info).toHaveBeenCalledOnce();
|
|
14
|
+
const line = info.mock.calls[0][0] as string;
|
|
15
|
+
expect(line).toMatch(/^\[linear:diagnostic\] \{/);
|
|
16
|
+
const json = JSON.parse(line.replace("[linear:diagnostic] ", ""));
|
|
17
|
+
expect(json.event).toBe("webhook_received");
|
|
18
|
+
expect(json.identifier).toBe("ISS-42");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("includes all payload fields in JSON output", () => {
|
|
22
|
+
const info = vi.fn();
|
|
23
|
+
const api = makeApi(info);
|
|
24
|
+
const payload: DiagnosticPayload = {
|
|
25
|
+
event: "dispatch_started",
|
|
26
|
+
identifier: "ISS-99",
|
|
27
|
+
issueId: "abc-123",
|
|
28
|
+
phase: "planning",
|
|
29
|
+
from: "triage",
|
|
30
|
+
to: "execution",
|
|
31
|
+
attempt: 2,
|
|
32
|
+
tier: "gold",
|
|
33
|
+
webhookType: "Comment",
|
|
34
|
+
webhookAction: "create",
|
|
35
|
+
channel: "discord",
|
|
36
|
+
target: "kaylee",
|
|
37
|
+
error: "none",
|
|
38
|
+
durationMs: 1234,
|
|
39
|
+
};
|
|
40
|
+
emitDiagnostic(api, payload);
|
|
41
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
42
|
+
expect(json).toMatchObject(payload);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("works with partial payload (only event + identifier)", () => {
|
|
46
|
+
const info = vi.fn();
|
|
47
|
+
const api = makeApi(info);
|
|
48
|
+
emitDiagnostic(api, { event: "health_check" });
|
|
49
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
50
|
+
expect(json.event).toBe("health_check");
|
|
51
|
+
expect(json.identifier).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("never throws even if logger throws", () => {
|
|
55
|
+
const api = makeApi(() => { throw new Error("logger exploded"); });
|
|
56
|
+
expect(() => {
|
|
57
|
+
emitDiagnostic(api, { event: "notify_failed", identifier: "ISS-1" });
|
|
58
|
+
}).not.toThrow();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("includes timestamp-relevant fields — payload is faithfully serialized", () => {
|
|
62
|
+
const info = vi.fn();
|
|
63
|
+
const api = makeApi(info);
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
emitDiagnostic(api, { event: "phase_transition", identifier: "ISS-7", timestamp: now } as any);
|
|
66
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
67
|
+
expect(json.timestamp).toBe(now);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles payload with special characters", () => {
|
|
71
|
+
const info = vi.fn();
|
|
72
|
+
const api = makeApi(info);
|
|
73
|
+
emitDiagnostic(api, {
|
|
74
|
+
event: "notify_sent",
|
|
75
|
+
identifier: 'ISS-"special"',
|
|
76
|
+
error: "line1\nline2\ttab",
|
|
77
|
+
channel: "<script>alert('xss')</script>",
|
|
78
|
+
});
|
|
79
|
+
expect(info).toHaveBeenCalledOnce();
|
|
80
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
81
|
+
expect(json.identifier).toBe('ISS-"special"');
|
|
82
|
+
expect(json.error).toBe("line1\nline2\ttab");
|
|
83
|
+
expect(json.channel).toBe("<script>alert('xss')</script>");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -337,11 +337,13 @@ describe("buildSummaryFromArtifacts", () => {
|
|
|
337
337
|
// ---------------------------------------------------------------------------
|
|
338
338
|
|
|
339
339
|
describe("writeDispatchMemory", () => {
|
|
340
|
-
it("creates memory/ dir and writes file", () => {
|
|
340
|
+
it("creates memory/ dir and writes file with frontmatter", () => {
|
|
341
341
|
const tmp = makeTmpDir();
|
|
342
342
|
writeDispatchMemory("API-100", "summary content", tmp);
|
|
343
343
|
const content = readFileSync(join(tmp, "memory", "dispatch-API-100.md"), "utf-8");
|
|
344
|
-
expect(content).
|
|
344
|
+
expect(content).toContain("---\n");
|
|
345
|
+
expect(content).toContain('issue: "API-100"');
|
|
346
|
+
expect(content).toContain("summary content");
|
|
345
347
|
});
|
|
346
348
|
|
|
347
349
|
it("overwrites on second call", () => {
|
|
@@ -349,7 +351,28 @@ describe("writeDispatchMemory", () => {
|
|
|
349
351
|
writeDispatchMemory("API-100", "first", tmp);
|
|
350
352
|
writeDispatchMemory("API-100", "second", tmp);
|
|
351
353
|
const content = readFileSync(join(tmp, "memory", "dispatch-API-100.md"), "utf-8");
|
|
352
|
-
expect(content).
|
|
354
|
+
expect(content).toContain("second");
|
|
355
|
+
expect(content).not.toContain("first");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("includes custom metadata in frontmatter", () => {
|
|
359
|
+
const tmp = makeTmpDir();
|
|
360
|
+
writeDispatchMemory("CT-50", "done summary", tmp, {
|
|
361
|
+
title: "Fix login bug",
|
|
362
|
+
tier: "senior",
|
|
363
|
+
status: "done",
|
|
364
|
+
project: "Auth",
|
|
365
|
+
attempts: 2,
|
|
366
|
+
model: "kimi-k2.5",
|
|
367
|
+
});
|
|
368
|
+
const content = readFileSync(join(tmp, "memory", "dispatch-CT-50.md"), "utf-8");
|
|
369
|
+
expect(content).toContain('title: "Fix login bug"');
|
|
370
|
+
expect(content).toContain('tier: "senior"');
|
|
371
|
+
expect(content).toContain('status: "done"');
|
|
372
|
+
expect(content).toContain('project: "Auth"');
|
|
373
|
+
expect(content).toContain("attempts: 2");
|
|
374
|
+
expect(content).toContain('model: "kimi-k2.5"');
|
|
375
|
+
expect(content).toContain("done summary");
|
|
353
376
|
});
|
|
354
377
|
});
|
|
355
378
|
|
|
@@ -58,6 +58,7 @@ export interface ActiveDispatch {
|
|
|
58
58
|
auditSessionKey?: string; // session key for current audit sub-agent
|
|
59
59
|
stuckReason?: string; // only set when status === "stuck"
|
|
60
60
|
issueTitle?: string; // for artifact summaries and memory headings
|
|
61
|
+
worktrees?: Array<{ repoName: string; path: string; branch: string }>;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export interface CompletedDispatch {
|