@calltelemetry/openclaw-linear 0.7.1 → 0.8.1
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 +834 -536
- package/index.ts +1 -1
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
- package/prompts.yaml +46 -6
- 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 +192 -0
- package/src/agent/agent.ts +26 -1
- 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 +30 -25
- 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 +478 -0
- package/src/pipeline/intent-classify.test.ts +285 -0
- package/src/pipeline/intent-classify.ts +259 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +47 -18
- package/src/pipeline/planner.test.ts +159 -40
- package/src/pipeline/planner.ts +108 -60
- package/src/pipeline/tier-assess.test.ts +89 -0
- package/src/pipeline/webhook.ts +424 -251
- package/src/tools/claude-tool.ts +6 -0
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/code-tool.ts +2 -2
- package/src/tools/dispatch-history-tool.test.ts +315 -0
- package/src/tools/planner-tools.test.ts +1 -1
- package/src/tools/planner-tools.ts +10 -2
package/src/tools/claude-tool.ts
CHANGED
|
@@ -150,6 +150,12 @@ export async function runClaude(
|
|
|
150
150
|
const env = { ...process.env };
|
|
151
151
|
delete env.CLAUDECODE;
|
|
152
152
|
|
|
153
|
+
// Pass Anthropic API key if configured (plugin config takes precedence over env)
|
|
154
|
+
const claudeApiKey = pluginConfig?.claudeApiKey as string | undefined;
|
|
155
|
+
if (claudeApiKey) {
|
|
156
|
+
env.ANTHROPIC_API_KEY = claudeApiKey;
|
|
157
|
+
}
|
|
158
|
+
|
|
153
159
|
const child = spawn(CLAUDE_BIN, args, {
|
|
154
160
|
stdio: ["ignore", "pipe", "pipe"],
|
|
155
161
|
cwd: workingDir,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock the active-session module
|
|
4
|
+
vi.mock("../pipeline/active-session.js", () => ({
|
|
5
|
+
getCurrentSession: vi.fn(() => null),
|
|
6
|
+
getActiveSessionByIdentifier: vi.fn(() => null),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
// Mock the linear-api module — LinearAgentApi must be a class (used with `new`)
|
|
10
|
+
vi.mock("../api/linear-api.js", () => {
|
|
11
|
+
const MockClass = vi.fn(function (this: any) {
|
|
12
|
+
return this;
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
resolveLinearToken: vi.fn(() => ({ accessToken: null, source: "none" })),
|
|
16
|
+
LinearAgentApi: MockClass,
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Mock the watchdog module (re-exported constants)
|
|
21
|
+
vi.mock("../agent/watchdog.js", () => ({
|
|
22
|
+
DEFAULT_INACTIVITY_SEC: 120,
|
|
23
|
+
DEFAULT_MAX_TOTAL_SEC: 7200,
|
|
24
|
+
DEFAULT_TOOL_TIMEOUT_SEC: 600,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { getCurrentSession, getActiveSessionByIdentifier } from "../pipeline/active-session.js";
|
|
28
|
+
import { resolveLinearToken, LinearAgentApi } from "../api/linear-api.js";
|
|
29
|
+
import { extractPrompt, resolveSession, buildLinearApi } from "./cli-shared.js";
|
|
30
|
+
import type { CliToolParams } from "./cli-shared.js";
|
|
31
|
+
|
|
32
|
+
const mockedGetCurrentSession = vi.mocked(getCurrentSession);
|
|
33
|
+
const mockedGetActiveByIdentifier = vi.mocked(getActiveSessionByIdentifier);
|
|
34
|
+
const mockedResolveLinearToken = vi.mocked(resolveLinearToken);
|
|
35
|
+
const MockedLinearAgentApi = vi.mocked(LinearAgentApi);
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.clearAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// extractPrompt
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
describe("extractPrompt", () => {
|
|
45
|
+
it("returns prompt field when present", () => {
|
|
46
|
+
const params: CliToolParams = { prompt: "do the thing" };
|
|
47
|
+
expect(extractPrompt(params)).toBe("do the thing");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("falls back to text field", () => {
|
|
51
|
+
const params = { text: "text fallback" } as unknown as CliToolParams;
|
|
52
|
+
expect(extractPrompt(params)).toBe("text fallback");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("falls back to message field", () => {
|
|
56
|
+
const params = { message: "message fallback" } as unknown as CliToolParams;
|
|
57
|
+
expect(extractPrompt(params)).toBe("message fallback");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to task field", () => {
|
|
61
|
+
const params = { task: "task fallback" } as unknown as CliToolParams;
|
|
62
|
+
expect(extractPrompt(params)).toBe("task fallback");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns undefined when no fields present", () => {
|
|
66
|
+
const params = {} as unknown as CliToolParams;
|
|
67
|
+
expect(extractPrompt(params)).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// resolveSession
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
describe("resolveSession", () => {
|
|
75
|
+
it("uses explicit session params when provided", () => {
|
|
76
|
+
const params: CliToolParams = {
|
|
77
|
+
prompt: "test",
|
|
78
|
+
agentSessionId: "sess-123",
|
|
79
|
+
issueIdentifier: "API-42",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const result = resolveSession(params);
|
|
83
|
+
expect(result.agentSessionId).toBe("sess-123");
|
|
84
|
+
expect(result.issueIdentifier).toBe("API-42");
|
|
85
|
+
// Should not consult the registry at all
|
|
86
|
+
expect(mockedGetCurrentSession).not.toHaveBeenCalled();
|
|
87
|
+
expect(mockedGetActiveByIdentifier).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("falls back to active session registry", () => {
|
|
91
|
+
mockedGetCurrentSession.mockReturnValue({
|
|
92
|
+
agentSessionId: "active-sess-456",
|
|
93
|
+
issueIdentifier: "API-99",
|
|
94
|
+
issueId: "issue-id-99",
|
|
95
|
+
startedAt: Date.now(),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const params: CliToolParams = { prompt: "test" };
|
|
99
|
+
const result = resolveSession(params);
|
|
100
|
+
|
|
101
|
+
expect(result.agentSessionId).toBe("active-sess-456");
|
|
102
|
+
expect(result.issueIdentifier).toBe("API-99");
|
|
103
|
+
expect(mockedGetCurrentSession).toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// buildLinearApi
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
describe("buildLinearApi", () => {
|
|
111
|
+
it("returns null when no agentSessionId provided", () => {
|
|
112
|
+
const api = { pluginConfig: {} } as any;
|
|
113
|
+
expect(buildLinearApi(api)).toBeNull();
|
|
114
|
+
expect(buildLinearApi(api, undefined)).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns null when no token available", () => {
|
|
118
|
+
mockedResolveLinearToken.mockReturnValue({
|
|
119
|
+
accessToken: null,
|
|
120
|
+
source: "none",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const api = { pluginConfig: {} } as any;
|
|
124
|
+
expect(buildLinearApi(api, "sess-123")).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("creates a LinearAgentApi when token is available", () => {
|
|
128
|
+
mockedResolveLinearToken.mockReturnValue({
|
|
129
|
+
accessToken: "lin_tok_abc",
|
|
130
|
+
refreshToken: "refresh_xyz",
|
|
131
|
+
expiresAt: 9999999999,
|
|
132
|
+
source: "profile",
|
|
133
|
+
});
|
|
134
|
+
MockedLinearAgentApi.mockImplementation(function (this: any) {
|
|
135
|
+
this.fake = true;
|
|
136
|
+
return this;
|
|
137
|
+
} as any);
|
|
138
|
+
|
|
139
|
+
const api = {
|
|
140
|
+
pluginConfig: {
|
|
141
|
+
clientId: "cid",
|
|
142
|
+
clientSecret: "csecret",
|
|
143
|
+
},
|
|
144
|
+
} as any;
|
|
145
|
+
|
|
146
|
+
const result = buildLinearApi(api, "sess-123");
|
|
147
|
+
expect(result).not.toBeNull();
|
|
148
|
+
expect(MockedLinearAgentApi).toHaveBeenCalledWith("lin_tok_abc", {
|
|
149
|
+
refreshToken: "refresh_xyz",
|
|
150
|
+
expiresAt: 9999999999,
|
|
151
|
+
clientId: "cid",
|
|
152
|
+
clientSecret: "csecret",
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -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 codex when no config provided", () => {
|
|
207
|
+
expect(resolveCodingBackend({})).toBe("codex");
|
|
208
|
+
expect(resolveCodingBackend({}, "anyAgent")).toBe("codex");
|
|
209
|
+
});
|
|
210
|
+
});
|
package/src/tools/code-tool.ts
CHANGED
|
@@ -88,7 +88,7 @@ function resolveAlias(aliasMap: Map<string, CodingBackend>, input: string): Codi
|
|
|
88
88
|
* Priority:
|
|
89
89
|
* 1. Per-agent override: config.agentCodingTools[agentId]
|
|
90
90
|
* 2. Global default: config.codingTool
|
|
91
|
-
* 3. Hardcoded fallback: "
|
|
91
|
+
* 3. Hardcoded fallback: "codex"
|
|
92
92
|
*/
|
|
93
93
|
export function resolveCodingBackend(
|
|
94
94
|
config: CodingToolsConfig,
|
|
@@ -104,7 +104,7 @@ export function resolveCodingBackend(
|
|
|
104
104
|
const global = config.codingTool;
|
|
105
105
|
if (global && global in BACKEND_RUNNERS) return global as CodingBackend;
|
|
106
106
|
|
|
107
|
-
return "
|
|
107
|
+
return "codex";
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
/**
|