@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.
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock node:fs so loadCodingConfig can be tested
4
+ vi.mock("node:fs", () => ({
5
+ readFileSync: vi.fn(),
6
+ }));
7
+
8
+ // Mock heavy runner dependencies — we only test resolution/config logic
9
+ vi.mock("./codex-tool.js", () => ({
10
+ runCodex: vi.fn(),
11
+ }));
12
+ vi.mock("./claude-tool.js", () => ({
13
+ runClaude: vi.fn(),
14
+ }));
15
+ vi.mock("./gemini-tool.js", () => ({
16
+ runGemini: vi.fn(),
17
+ }));
18
+ vi.mock("../pipeline/active-session.js", () => ({
19
+ getCurrentSession: vi.fn(() => null),
20
+ }));
21
+ vi.mock("openclaw/plugin-sdk", () => ({
22
+ jsonResult: vi.fn((v: unknown) => v),
23
+ }));
24
+
25
+ import { readFileSync } from "node:fs";
26
+ import type { CodingToolsConfig } from "./code-tool.js";
27
+ import { loadCodingConfig, resolveCodingBackend } from "./code-tool.js";
28
+
29
+ // buildAliasMap and resolveAlias are not exported, so we test them indirectly
30
+ // through the module's behaviour. However, for direct testing we can re-derive
31
+ // the same logic here with a small helper that mirrors the source (or just
32
+ // test through resolveCodingBackend / createCodeTool).
33
+ //
34
+ // Since buildAliasMap and resolveAlias are private, we import the module source
35
+ // and test their effects through the public API. For unit-level tests we
36
+ // replicate the minimal logic inline.
37
+
38
+ // Inline copies of the private helpers, matching the source exactly.
39
+ // This lets us unit-test alias mapping without exporting internals.
40
+ type CodingBackend = "claude" | "codex" | "gemini";
41
+
42
+ const BACKEND_IDS: CodingBackend[] = ["claude", "codex", "gemini"];
43
+
44
+ function buildAliasMap(config: CodingToolsConfig): Map<string, CodingBackend> {
45
+ const map = new Map<string, CodingBackend>();
46
+ for (const backendId of BACKEND_IDS) {
47
+ map.set(backendId, backendId);
48
+ const aliases = config.backends?.[backendId]?.aliases;
49
+ if (aliases) {
50
+ for (const alias of aliases) {
51
+ map.set(alias.toLowerCase(), backendId);
52
+ }
53
+ }
54
+ }
55
+ return map;
56
+ }
57
+
58
+ function resolveAlias(
59
+ aliasMap: Map<string, CodingBackend>,
60
+ input: string,
61
+ ): CodingBackend | undefined {
62
+ return aliasMap.get(input.toLowerCase());
63
+ }
64
+
65
+ const mockedReadFileSync = vi.mocked(readFileSync);
66
+
67
+ beforeEach(() => {
68
+ vi.clearAllMocks();
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // loadCodingConfig
73
+ // ---------------------------------------------------------------------------
74
+ describe("loadCodingConfig", () => {
75
+ it("loads valid coding-tools.json", () => {
76
+ const validConfig: CodingToolsConfig = {
77
+ codingTool: "gemini",
78
+ agentCodingTools: { kaylee: "codex" },
79
+ backends: {
80
+ gemini: { aliases: ["gem"] },
81
+ },
82
+ };
83
+ mockedReadFileSync.mockReturnValue(JSON.stringify(validConfig));
84
+
85
+ const result = loadCodingConfig();
86
+ expect(result).toEqual(validConfig);
87
+ });
88
+
89
+ it("returns defaults when file not found", () => {
90
+ mockedReadFileSync.mockImplementation(() => {
91
+ throw new Error("ENOENT: no such file or directory");
92
+ });
93
+
94
+ const result = loadCodingConfig();
95
+ expect(result).toEqual({});
96
+ });
97
+
98
+ it("returns defaults for invalid JSON", () => {
99
+ mockedReadFileSync.mockReturnValue("{ not valid json !!!");
100
+
101
+ const result = loadCodingConfig();
102
+ expect(result).toEqual({});
103
+ });
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // buildAliasMap
108
+ // ---------------------------------------------------------------------------
109
+ describe("buildAliasMap", () => {
110
+ it("maps backend IDs as aliases", () => {
111
+ const map = buildAliasMap({});
112
+
113
+ expect(map.get("claude")).toBe("claude");
114
+ expect(map.get("codex")).toBe("codex");
115
+ expect(map.get("gemini")).toBe("gemini");
116
+ });
117
+
118
+ it("includes configured aliases from backends", () => {
119
+ const config: CodingToolsConfig = {
120
+ backends: {
121
+ claude: { aliases: ["CC", "anthropic"] },
122
+ gemini: { aliases: ["Gem", "Google"] },
123
+ },
124
+ };
125
+
126
+ const map = buildAliasMap(config);
127
+
128
+ // Aliases should be lowercased
129
+ expect(map.get("cc")).toBe("claude");
130
+ expect(map.get("anthropic")).toBe("claude");
131
+ expect(map.get("gem")).toBe("gemini");
132
+ expect(map.get("google")).toBe("gemini");
133
+
134
+ // Backend IDs still present
135
+ expect(map.get("claude")).toBe("claude");
136
+ expect(map.get("gemini")).toBe("gemini");
137
+ expect(map.get("codex")).toBe("codex");
138
+ });
139
+ });
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // resolveAlias
143
+ // ---------------------------------------------------------------------------
144
+ describe("resolveAlias", () => {
145
+ const config: CodingToolsConfig = {
146
+ backends: {
147
+ codex: { aliases: ["OpenAI", "ox"] },
148
+ },
149
+ };
150
+ const aliasMap = buildAliasMap(config);
151
+
152
+ it("finds match case-insensitively", () => {
153
+ expect(resolveAlias(aliasMap, "OPENAI")).toBe("codex");
154
+ expect(resolveAlias(aliasMap, "openai")).toBe("codex");
155
+ expect(resolveAlias(aliasMap, "OX")).toBe("codex");
156
+ expect(resolveAlias(aliasMap, "Claude")).toBe("claude");
157
+ expect(resolveAlias(aliasMap, "GEMINI")).toBe("gemini");
158
+ });
159
+
160
+ it("returns undefined for unknown alias", () => {
161
+ expect(resolveAlias(aliasMap, "unknown-backend")).toBeUndefined();
162
+ expect(resolveAlias(aliasMap, "gpt")).toBeUndefined();
163
+ expect(resolveAlias(aliasMap, "")).toBeUndefined();
164
+ });
165
+ });
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // resolveCodingBackend
169
+ // ---------------------------------------------------------------------------
170
+ describe("resolveCodingBackend", () => {
171
+ it("uses explicit backend parameter (per-agent override)", () => {
172
+ const config: CodingToolsConfig = {
173
+ codingTool: "gemini",
174
+ agentCodingTools: { kaylee: "codex" },
175
+ };
176
+
177
+ // Even though global says gemini, kaylee has codex
178
+ expect(resolveCodingBackend(config, "kaylee")).toBe("codex");
179
+ });
180
+
181
+ it("uses per-agent override from agentCodingTools", () => {
182
+ const config: CodingToolsConfig = {
183
+ codingTool: "claude",
184
+ agentCodingTools: {
185
+ inara: "gemini",
186
+ mal: "codex",
187
+ },
188
+ };
189
+
190
+ expect(resolveCodingBackend(config, "inara")).toBe("gemini");
191
+ expect(resolveCodingBackend(config, "mal")).toBe("codex");
192
+ });
193
+
194
+ it("falls back to global codingTool default", () => {
195
+ const config: CodingToolsConfig = {
196
+ codingTool: "gemini",
197
+ agentCodingTools: { kaylee: "codex" },
198
+ };
199
+
200
+ // Agent "mal" has no override, so global "gemini" is used
201
+ expect(resolveCodingBackend(config, "mal")).toBe("gemini");
202
+ // No agent ID at all
203
+ expect(resolveCodingBackend(config)).toBe("gemini");
204
+ });
205
+
206
+ it("falls back to claude when no config provided", () => {
207
+ expect(resolveCodingBackend({})).toBe("claude");
208
+ expect(resolveCodingBackend({}, "anyAgent")).toBe("claude");
209
+ });
210
+ });
@@ -0,0 +1,315 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks
5
+ // ---------------------------------------------------------------------------
6
+
7
+ // Mock openclaw/plugin-sdk
8
+ vi.mock("openclaw/plugin-sdk", () => ({
9
+ jsonResult: (data: any) => ({ type: "json", data }),
10
+ }));
11
+
12
+ // Mock dispatch-state
13
+ vi.mock("../pipeline/dispatch-state.js", () => ({
14
+ readDispatchState: vi.fn(),
15
+ listActiveDispatches: vi.fn(),
16
+ }));
17
+
18
+ // Mock artifacts — resolveOrchestratorWorkspace
19
+ vi.mock("../pipeline/artifacts.js", () => ({
20
+ resolveOrchestratorWorkspace: vi.fn(() => "/mock/workspace"),
21
+ }));
22
+
23
+ // Mock node:fs (readdirSync, readFileSync)
24
+ vi.mock("node:fs", () => ({
25
+ readdirSync: vi.fn(() => []),
26
+ readFileSync: vi.fn(() => ""),
27
+ }));
28
+
29
+ import { createDispatchHistoryTool } from "./dispatch-history-tool.js";
30
+ import { readDispatchState, listActiveDispatches } from "../pipeline/dispatch-state.js";
31
+ import { readdirSync, readFileSync } from "node:fs";
32
+ import type { DispatchState, ActiveDispatch, CompletedDispatch } from "../pipeline/dispatch-state.js";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const mockReadDispatchState = readDispatchState as ReturnType<typeof vi.fn>;
39
+ const mockListActiveDispatches = listActiveDispatches as ReturnType<typeof vi.fn>;
40
+ const mockReaddirSync = readdirSync as unknown as ReturnType<typeof vi.fn>;
41
+ const mockReadFileSync = readFileSync as unknown as ReturnType<typeof vi.fn>;
42
+
43
+ function emptyState(): DispatchState {
44
+ return {
45
+ dispatches: { active: {}, completed: {} },
46
+ sessionMap: {},
47
+ processedEvents: [],
48
+ };
49
+ }
50
+
51
+ function makeActive(overrides?: Partial<ActiveDispatch>): ActiveDispatch {
52
+ return {
53
+ issueId: "uuid-1",
54
+ issueIdentifier: "CT-100",
55
+ worktreePath: "/tmp/wt/CT-100",
56
+ branch: "codex/CT-100",
57
+ tier: "junior",
58
+ model: "test-model",
59
+ status: "dispatched",
60
+ dispatchedAt: new Date().toISOString(),
61
+ attempt: 0,
62
+ ...overrides,
63
+ };
64
+ }
65
+
66
+ function makeCompleted(overrides?: Partial<CompletedDispatch>): CompletedDispatch {
67
+ return {
68
+ issueIdentifier: "CT-200",
69
+ tier: "senior",
70
+ status: "done",
71
+ completedAt: new Date().toISOString(),
72
+ totalAttempts: 2,
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ const fakeApi = {
78
+ runtime: { config: { loadConfig: () => ({}) } },
79
+ } as any;
80
+
81
+ function createTool(pluginConfig?: Record<string, unknown>) {
82
+ return createDispatchHistoryTool(fakeApi, pluginConfig) as any;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Tests
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe("dispatch_history tool", () => {
90
+ beforeEach(() => {
91
+ vi.clearAllMocks();
92
+ // Default: no memory files
93
+ mockReaddirSync.mockReturnValue([]);
94
+ mockReadFileSync.mockReturnValue("");
95
+ });
96
+
97
+ it("returns empty results when no dispatches exist", async () => {
98
+ mockReadDispatchState.mockResolvedValue(emptyState());
99
+ mockListActiveDispatches.mockReturnValue([]);
100
+
101
+ const tool = createTool();
102
+ const result = await tool.execute("call-1", {});
103
+
104
+ expect(result.data.results).toEqual([]);
105
+ expect(result.data.message).toContain("No dispatch history found");
106
+ });
107
+
108
+ it("finds active dispatch by identifier query", async () => {
109
+ const active = makeActive({ issueIdentifier: "CT-100", tier: "junior", status: "working" });
110
+ const state = emptyState();
111
+ state.dispatches.active["CT-100"] = active;
112
+
113
+ mockReadDispatchState.mockResolvedValue(state);
114
+ mockListActiveDispatches.mockReturnValue([active]);
115
+
116
+ const tool = createTool();
117
+ const result = await tool.execute("call-2", { query: "CT-100" });
118
+
119
+ expect(result.data.results).toHaveLength(1);
120
+ expect(result.data.results[0].identifier).toBe("CT-100");
121
+ expect(result.data.results[0].active).toBe(true);
122
+ });
123
+
124
+ it("finds completed dispatch by identifier query", async () => {
125
+ const completed = makeCompleted({ issueIdentifier: "CT-200", tier: "senior", status: "done" });
126
+ const state = emptyState();
127
+ state.dispatches.completed["CT-200"] = completed;
128
+
129
+ mockReadDispatchState.mockResolvedValue(state);
130
+ mockListActiveDispatches.mockReturnValue([]);
131
+
132
+ const tool = createTool();
133
+ const result = await tool.execute("call-3", { query: "CT-200" });
134
+
135
+ expect(result.data.results).toHaveLength(1);
136
+ expect(result.data.results[0].identifier).toBe("CT-200");
137
+ expect(result.data.results[0].active).toBe(false);
138
+ });
139
+
140
+ it("filters by tier", async () => {
141
+ const junior = makeActive({ issueIdentifier: "CT-10", tier: "junior", status: "working" });
142
+ const senior = makeActive({ issueIdentifier: "CT-20", tier: "senior", status: "working" });
143
+ const state = emptyState();
144
+ state.dispatches.active["CT-10"] = junior;
145
+ state.dispatches.active["CT-20"] = senior;
146
+
147
+ mockReadDispatchState.mockResolvedValue(state);
148
+ mockListActiveDispatches.mockReturnValue([junior, senior]);
149
+
150
+ const tool = createTool();
151
+ const result = await tool.execute("call-4", { tier: "senior" });
152
+
153
+ expect(result.data.results).toHaveLength(1);
154
+ expect(result.data.results[0].identifier).toBe("CT-20");
155
+ expect(result.data.results[0].tier).toBe("senior");
156
+ });
157
+
158
+ it("filters by status", async () => {
159
+ const working = makeActive({ issueIdentifier: "CT-10", status: "working" });
160
+ const auditing = makeActive({ issueIdentifier: "CT-20", status: "auditing" });
161
+ const state = emptyState();
162
+ state.dispatches.active["CT-10"] = working;
163
+ state.dispatches.active["CT-20"] = auditing;
164
+
165
+ mockReadDispatchState.mockResolvedValue(state);
166
+ mockListActiveDispatches.mockReturnValue([working, auditing]);
167
+
168
+ const tool = createTool();
169
+ const result = await tool.execute("call-5", { status: "working" });
170
+
171
+ expect(result.data.results).toHaveLength(1);
172
+ expect(result.data.results[0].identifier).toBe("CT-10");
173
+ expect(result.data.results[0].status).toBe("working");
174
+ });
175
+
176
+ it("combines tier + status filters", async () => {
177
+ const a = makeActive({ issueIdentifier: "CT-1", tier: "senior", status: "working" });
178
+ const b = makeActive({ issueIdentifier: "CT-2", tier: "junior", status: "working" });
179
+ const c = makeActive({ issueIdentifier: "CT-3", tier: "senior", status: "dispatched" });
180
+ const state = emptyState();
181
+ state.dispatches.active["CT-1"] = a;
182
+ state.dispatches.active["CT-2"] = b;
183
+ state.dispatches.active["CT-3"] = c;
184
+
185
+ mockReadDispatchState.mockResolvedValue(state);
186
+ mockListActiveDispatches.mockReturnValue([a, b, c]);
187
+
188
+ const tool = createTool();
189
+ const result = await tool.execute("call-6", { tier: "senior", status: "working" });
190
+
191
+ expect(result.data.results).toHaveLength(1);
192
+ expect(result.data.results[0].identifier).toBe("CT-1");
193
+ });
194
+
195
+ it("respects limit parameter", async () => {
196
+ const dispatches = Array.from({ length: 5 }, (_, i) =>
197
+ makeActive({ issueIdentifier: `CT-${i + 1}`, tier: "junior", status: "dispatched" }),
198
+ );
199
+ const state = emptyState();
200
+ for (const d of dispatches) {
201
+ state.dispatches.active[d.issueIdentifier] = d;
202
+ }
203
+
204
+ mockReadDispatchState.mockResolvedValue(state);
205
+ mockListActiveDispatches.mockReturnValue(dispatches);
206
+
207
+ const tool = createTool();
208
+ const result = await tool.execute("call-7", { limit: 2 });
209
+
210
+ expect(result.data.results).toHaveLength(2);
211
+ });
212
+
213
+ it("matches query substring in memory file content", async () => {
214
+ // No active or completed dispatches — result comes only from memory file.
215
+ // The tool's matchesFilters requires the query to appear in the identifier,
216
+ // so we search for "CT-300" which matches the identifier AND will find
217
+ // the memory file whose content has extra context that gets extracted as summary.
218
+ const state = emptyState();
219
+ mockReadDispatchState.mockResolvedValue(state);
220
+ mockListActiveDispatches.mockReturnValue([]);
221
+
222
+ // Memory file for CT-300 contains extra context
223
+ mockReaddirSync.mockReturnValue(["dispatch-CT-300.md"]);
224
+ mockReadFileSync.mockReturnValue(
225
+ "---\ntier: medior\nstatus: done\nattempts: 1\n---\nApplied a workaround for the flaky test in CT-300.",
226
+ );
227
+
228
+ const tool = createTool();
229
+ const result = await tool.execute("call-8", { query: "CT-300" });
230
+
231
+ expect(result.data.results).toHaveLength(1);
232
+ expect(result.data.results[0].identifier).toBe("CT-300");
233
+ expect(result.data.results[0].tier).toBe("medior");
234
+ expect(result.data.results[0].summary).toContain("workaround");
235
+ });
236
+
237
+ it("returns active flag true for active dispatches", async () => {
238
+ const active = makeActive({ issueIdentifier: "CT-50", status: "working" });
239
+ const state = emptyState();
240
+ state.dispatches.active["CT-50"] = active;
241
+
242
+ mockReadDispatchState.mockResolvedValue(state);
243
+ mockListActiveDispatches.mockReturnValue([active]);
244
+
245
+ const tool = createTool();
246
+ const result = await tool.execute("call-9", {});
247
+
248
+ expect(result.data.results[0].active).toBe(true);
249
+ });
250
+
251
+ it("returns active flag false for completed dispatches", async () => {
252
+ const completed = makeCompleted({ issueIdentifier: "CT-60" });
253
+ const state = emptyState();
254
+ state.dispatches.completed["CT-60"] = completed;
255
+
256
+ mockReadDispatchState.mockResolvedValue(state);
257
+ mockListActiveDispatches.mockReturnValue([]);
258
+
259
+ const tool = createTool();
260
+ const result = await tool.execute("call-10", { query: "CT-60" });
261
+
262
+ expect(result.data.results[0].active).toBe(false);
263
+ });
264
+
265
+ it("handles memory file read errors gracefully (skips, does not crash)", async () => {
266
+ const active = makeActive({ issueIdentifier: "CT-70", status: "working" });
267
+ const state = emptyState();
268
+ state.dispatches.active["CT-70"] = active;
269
+
270
+ mockReadDispatchState.mockResolvedValue(state);
271
+ mockListActiveDispatches.mockReturnValue([active]);
272
+
273
+ // Memory dir listing succeeds but reading individual files throws
274
+ mockReaddirSync.mockReturnValue(["dispatch-CT-70.md"]);
275
+ mockReadFileSync.mockImplementation(() => {
276
+ throw new Error("EACCES: permission denied");
277
+ });
278
+
279
+ const tool = createTool();
280
+ const result = await tool.execute("call-11", {});
281
+
282
+ // Should still return the active dispatch, just without summary enrichment
283
+ expect(result.data.results).toHaveLength(1);
284
+ expect(result.data.results[0].identifier).toBe("CT-70");
285
+ expect(result.data.results[0].summary).toBeUndefined();
286
+ });
287
+
288
+ it("returns structured result with identifier, tier, status, attempt fields", async () => {
289
+ const active = makeActive({
290
+ issueIdentifier: "CT-80",
291
+ tier: "medior",
292
+ status: "auditing",
293
+ attempt: 3,
294
+ });
295
+ const state = emptyState();
296
+ state.dispatches.active["CT-80"] = active;
297
+
298
+ mockReadDispatchState.mockResolvedValue(state);
299
+ mockListActiveDispatches.mockReturnValue([active]);
300
+
301
+ const tool = createTool();
302
+ const result = await tool.execute("call-12", {});
303
+
304
+ const entry = result.data.results[0];
305
+ expect(entry).toEqual(
306
+ expect.objectContaining({
307
+ identifier: "CT-80",
308
+ tier: "medior",
309
+ status: "auditing",
310
+ attempts: 3,
311
+ active: true,
312
+ }),
313
+ );
314
+ });
315
+ });