@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
|
@@ -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
|
+
});
|