@calltelemetry/openclaw-linear 0.8.7 → 0.9.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 +230 -89
- package/index.ts +36 -4
- package/package.json +1 -1
- package/src/__test__/webhook-scenarios.test.ts +1 -1
- package/src/gateway/dispatch-methods.test.ts +9 -9
- package/src/infra/commands.test.ts +5 -5
- package/src/infra/config-paths.test.ts +246 -0
- package/src/infra/doctor.ts +45 -36
- package/src/infra/notify.test.ts +49 -0
- package/src/infra/notify.ts +7 -2
- package/src/infra/observability.ts +1 -0
- package/src/infra/shared-profiles.test.ts +262 -0
- package/src/infra/shared-profiles.ts +116 -0
- package/src/infra/template.test.ts +86 -0
- package/src/infra/template.ts +18 -0
- package/src/infra/validation.test.ts +175 -0
- package/src/infra/validation.ts +52 -0
- package/src/pipeline/active-session.test.ts +2 -2
- package/src/pipeline/agent-end-hook.test.ts +305 -0
- package/src/pipeline/artifacts.test.ts +3 -3
- package/src/pipeline/dispatch-state.test.ts +111 -8
- package/src/pipeline/dispatch-state.ts +48 -13
- package/src/pipeline/e2e-dispatch.test.ts +2 -2
- package/src/pipeline/intent-classify.test.ts +20 -2
- package/src/pipeline/intent-classify.ts +14 -24
- package/src/pipeline/pipeline.ts +28 -11
- package/src/pipeline/planner.ts +1 -8
- package/src/pipeline/planning-state.ts +9 -0
- package/src/pipeline/tier-assess.test.ts +39 -39
- package/src/pipeline/tier-assess.ts +15 -33
- package/src/pipeline/webhook.test.ts +149 -1
- package/src/pipeline/webhook.ts +90 -62
- package/src/tools/dispatch-history-tool.test.ts +21 -20
- package/src/tools/dispatch-history-tool.ts +1 -1
- package/src/tools/linear-issues-tool.test.ts +115 -0
- package/src/tools/linear-issues-tool.ts +25 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mocks
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const { mockReadFileSync } = vi.hoisted(() => ({
|
|
8
|
+
mockReadFileSync: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("node:fs", () => ({
|
|
12
|
+
readFileSync: mockReadFileSync,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Imports (AFTER mocks)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
loadAgentProfiles,
|
|
21
|
+
buildMentionPattern,
|
|
22
|
+
resolveAgentFromAlias,
|
|
23
|
+
resolveDefaultAgent,
|
|
24
|
+
_resetProfilesCacheForTesting,
|
|
25
|
+
type AgentProfile,
|
|
26
|
+
} from "./shared-profiles.js";
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Fixtures
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const PROFILES_JSON = JSON.stringify({
|
|
33
|
+
agents: {
|
|
34
|
+
mal: {
|
|
35
|
+
label: "Mal",
|
|
36
|
+
mission: "Product owner",
|
|
37
|
+
mentionAliases: ["mason", "mal"],
|
|
38
|
+
appAliases: ["ctclaw"],
|
|
39
|
+
isDefault: true,
|
|
40
|
+
avatarUrl: "https://example.com/mal.png",
|
|
41
|
+
},
|
|
42
|
+
kaylee: {
|
|
43
|
+
label: "Kaylee",
|
|
44
|
+
mission: "Builder",
|
|
45
|
+
mentionAliases: ["eureka", "kaylee"],
|
|
46
|
+
avatarUrl: "https://example.com/kaylee.png",
|
|
47
|
+
},
|
|
48
|
+
inara: {
|
|
49
|
+
label: "Inara",
|
|
50
|
+
mission: "Content",
|
|
51
|
+
mentionAliases: ["forge", "inara"],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Reset
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
_resetProfilesCacheForTesting();
|
|
63
|
+
mockReadFileSync.mockReturnValue(PROFILES_JSON);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
_resetProfilesCacheForTesting();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// loadAgentProfiles
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
describe("loadAgentProfiles", () => {
|
|
75
|
+
it("loads and parses profiles from JSON file", () => {
|
|
76
|
+
const profiles = loadAgentProfiles();
|
|
77
|
+
|
|
78
|
+
expect(profiles).toHaveProperty("mal");
|
|
79
|
+
expect(profiles).toHaveProperty("kaylee");
|
|
80
|
+
expect(profiles).toHaveProperty("inara");
|
|
81
|
+
expect(profiles.mal.label).toBe("Mal");
|
|
82
|
+
expect(profiles.mal.isDefault).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("caches profiles for 5 seconds", () => {
|
|
86
|
+
loadAgentProfiles();
|
|
87
|
+
loadAgentProfiles();
|
|
88
|
+
loadAgentProfiles();
|
|
89
|
+
|
|
90
|
+
// Should only read file once (subsequent calls hit cache)
|
|
91
|
+
expect(mockReadFileSync).toHaveBeenCalledTimes(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("reloads after cache expires", () => {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
vi.spyOn(Date, "now").mockReturnValue(now);
|
|
97
|
+
|
|
98
|
+
loadAgentProfiles();
|
|
99
|
+
expect(mockReadFileSync).toHaveBeenCalledTimes(1);
|
|
100
|
+
|
|
101
|
+
// Advance past TTL (5s)
|
|
102
|
+
vi.spyOn(Date, "now").mockReturnValue(now + 6_000);
|
|
103
|
+
|
|
104
|
+
loadAgentProfiles();
|
|
105
|
+
expect(mockReadFileSync).toHaveBeenCalledTimes(2);
|
|
106
|
+
|
|
107
|
+
vi.restoreAllMocks();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns empty object when file is missing", () => {
|
|
111
|
+
mockReadFileSync.mockImplementation(() => {
|
|
112
|
+
throw new Error("ENOENT");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const profiles = loadAgentProfiles();
|
|
116
|
+
expect(profiles).toEqual({});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns empty object when JSON is invalid", () => {
|
|
120
|
+
mockReadFileSync.mockReturnValue("not valid json{{{");
|
|
121
|
+
|
|
122
|
+
const profiles = loadAgentProfiles();
|
|
123
|
+
expect(profiles).toEqual({});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns empty object when agents key is missing", () => {
|
|
127
|
+
mockReadFileSync.mockReturnValue(JSON.stringify({ version: 1 }));
|
|
128
|
+
|
|
129
|
+
const profiles = loadAgentProfiles();
|
|
130
|
+
expect(profiles).toEqual({});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// buildMentionPattern
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
describe("buildMentionPattern", () => {
|
|
139
|
+
it("builds regex matching all mention aliases", () => {
|
|
140
|
+
const profiles = loadAgentProfiles();
|
|
141
|
+
const pattern = buildMentionPattern(profiles);
|
|
142
|
+
|
|
143
|
+
expect(pattern).not.toBeNull();
|
|
144
|
+
// Use .match() instead of .test() to avoid global regex lastIndex statefulness
|
|
145
|
+
expect("@mason".match(pattern!)).not.toBeNull();
|
|
146
|
+
expect("@mal".match(pattern!)).not.toBeNull();
|
|
147
|
+
expect("@eureka".match(pattern!)).not.toBeNull();
|
|
148
|
+
expect("@kaylee".match(pattern!)).not.toBeNull();
|
|
149
|
+
expect("@forge".match(pattern!)).not.toBeNull();
|
|
150
|
+
expect("@inara".match(pattern!)).not.toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does NOT match appAliases", () => {
|
|
154
|
+
const profiles = loadAgentProfiles();
|
|
155
|
+
const pattern = buildMentionPattern(profiles);
|
|
156
|
+
|
|
157
|
+
// appAliases like "ctclaw" should not be in the mention pattern
|
|
158
|
+
expect("@ctclaw".match(pattern!)).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("is case-insensitive", () => {
|
|
162
|
+
const profiles = loadAgentProfiles();
|
|
163
|
+
const pattern = buildMentionPattern(profiles);
|
|
164
|
+
|
|
165
|
+
expect("@Mason".match(pattern!)).not.toBeNull();
|
|
166
|
+
expect("@KAYLEE".match(pattern!)).not.toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("returns null when no profiles have aliases", () => {
|
|
170
|
+
const pattern = buildMentionPattern({});
|
|
171
|
+
expect(pattern).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("returns null when all aliases are empty arrays", () => {
|
|
175
|
+
const pattern = buildMentionPattern({
|
|
176
|
+
agent1: { label: "A", mission: "test", mentionAliases: [] },
|
|
177
|
+
});
|
|
178
|
+
expect(pattern).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("escapes regex special chars in aliases", () => {
|
|
182
|
+
const profiles: Record<string, AgentProfile> = {
|
|
183
|
+
test: {
|
|
184
|
+
label: "Test",
|
|
185
|
+
mission: "test",
|
|
186
|
+
mentionAliases: ["agent.name", "agent+plus"],
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const pattern = buildMentionPattern(profiles);
|
|
190
|
+
expect(pattern).not.toBeNull();
|
|
191
|
+
// Should match literal dot, not "any char"
|
|
192
|
+
expect(pattern!.test("@agent.name")).toBe(true);
|
|
193
|
+
expect(pattern!.test("@agentXname")).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// resolveAgentFromAlias
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
describe("resolveAgentFromAlias", () => {
|
|
202
|
+
it("resolves known alias to agent", () => {
|
|
203
|
+
const profiles = loadAgentProfiles();
|
|
204
|
+
const result = resolveAgentFromAlias("mason", profiles);
|
|
205
|
+
|
|
206
|
+
expect(result).toEqual({ agentId: "mal", label: "Mal" });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("resolves case-insensitively", () => {
|
|
210
|
+
const profiles = loadAgentProfiles();
|
|
211
|
+
const result = resolveAgentFromAlias("EUREKA", profiles);
|
|
212
|
+
|
|
213
|
+
expect(result).toEqual({ agentId: "kaylee", label: "Kaylee" });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("returns null for unknown alias", () => {
|
|
217
|
+
const profiles = loadAgentProfiles();
|
|
218
|
+
const result = resolveAgentFromAlias("wash", profiles);
|
|
219
|
+
|
|
220
|
+
expect(result).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns null for empty profiles", () => {
|
|
224
|
+
const result = resolveAgentFromAlias("anything", {});
|
|
225
|
+
expect(result).toBeNull();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// resolveDefaultAgent
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
describe("resolveDefaultAgent", () => {
|
|
234
|
+
it("returns defaultAgentId from pluginConfig when set", () => {
|
|
235
|
+
const api = { pluginConfig: { defaultAgentId: "kaylee" } } as any;
|
|
236
|
+
const result = resolveDefaultAgent(api);
|
|
237
|
+
expect(result).toBe("kaylee");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("falls back to isDefault profile when no config", () => {
|
|
241
|
+
const api = { pluginConfig: {} } as any;
|
|
242
|
+
const result = resolveDefaultAgent(api);
|
|
243
|
+
expect(result).toBe("mal");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("returns 'default' when no config and no profiles", () => {
|
|
247
|
+
mockReadFileSync.mockImplementation(() => {
|
|
248
|
+
throw new Error("ENOENT");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const api = { pluginConfig: {} } as any;
|
|
252
|
+
const result = resolveDefaultAgent(api);
|
|
253
|
+
expect(result).toBe("default");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("ignores empty string in pluginConfig", () => {
|
|
257
|
+
const api = { pluginConfig: { defaultAgentId: "" } } as any;
|
|
258
|
+
const result = resolveDefaultAgent(api);
|
|
259
|
+
// Should fall through to profile default
|
|
260
|
+
expect(result).toBe("mal");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared-profiles.ts — Shared agent profile loader with TTL cache.
|
|
3
|
+
*
|
|
4
|
+
* Consolidates the duplicate loadAgentProfiles() / buildMentionPattern() /
|
|
5
|
+
* resolveAgentFromAlias() implementations that were previously in
|
|
6
|
+
* webhook.ts, intent-classify.ts, and tier-assess.ts.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface AgentProfile {
|
|
16
|
+
label: string;
|
|
17
|
+
mission: string;
|
|
18
|
+
mentionAliases: string[];
|
|
19
|
+
appAliases?: string[];
|
|
20
|
+
isDefault?: boolean;
|
|
21
|
+
avatarUrl?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Cached profile loader (5s TTL)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const PROFILES_PATH = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
|
|
29
|
+
|
|
30
|
+
let profilesCache: { data: Record<string, AgentProfile>; loadedAt: number } | null = null;
|
|
31
|
+
const PROFILES_CACHE_TTL_MS = 5_000;
|
|
32
|
+
|
|
33
|
+
export function loadAgentProfiles(): Record<string, AgentProfile> {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
if (profilesCache && now - profilesCache.loadedAt < PROFILES_CACHE_TTL_MS) {
|
|
36
|
+
return profilesCache.data;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const raw = readFileSync(PROFILES_PATH, "utf8");
|
|
40
|
+
const data = JSON.parse(raw).agents ?? {};
|
|
41
|
+
profilesCache = { data, loadedAt: now };
|
|
42
|
+
return data;
|
|
43
|
+
} catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Mention pattern builder
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a regex that matches @mentions for all agent mentionAliases.
|
|
54
|
+
* appAliases are excluded — those trigger AgentSessionEvent instead.
|
|
55
|
+
*/
|
|
56
|
+
export function buildMentionPattern(profiles: Record<string, AgentProfile>): RegExp | null {
|
|
57
|
+
const aliases: string[] = [];
|
|
58
|
+
for (const [, profile] of Object.entries(profiles)) {
|
|
59
|
+
aliases.push(...profile.mentionAliases);
|
|
60
|
+
}
|
|
61
|
+
if (aliases.length === 0) return null;
|
|
62
|
+
const escaped = aliases.map(a => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
63
|
+
return new RegExp(`@(${escaped.join("|")})`, "gi");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Agent resolver
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Given a mention alias string (e.g. "kaylee"), resolve which agent it
|
|
72
|
+
* belongs to. Returns { agentId, label } or null if no match.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveAgentFromAlias(
|
|
75
|
+
alias: string,
|
|
76
|
+
profiles: Record<string, AgentProfile>,
|
|
77
|
+
): { agentId: string; label: string } | null {
|
|
78
|
+
const lower = alias.toLowerCase();
|
|
79
|
+
for (const [agentId, profile] of Object.entries(profiles)) {
|
|
80
|
+
if (profile.mentionAliases.some(a => a.toLowerCase() === lower)) {
|
|
81
|
+
return { agentId, label: profile.label };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Default agent resolver (shared helper for intent-classify / tier-assess)
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the default agent ID from plugin config or agent profiles.
|
|
93
|
+
* Falls back to "default" if nothing is configured.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveDefaultAgent(api: { pluginConfig?: Record<string, unknown> }): string {
|
|
96
|
+
const fromConfig = (api as any).pluginConfig?.defaultAgentId;
|
|
97
|
+
if (typeof fromConfig === "string" && fromConfig) return fromConfig;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const raw = readFileSync(PROFILES_PATH, "utf8");
|
|
101
|
+
const profiles = JSON.parse(raw).agents ?? {};
|
|
102
|
+
const defaultAgent = Object.entries(profiles).find(([, p]: [string, any]) => p.isDefault);
|
|
103
|
+
if (defaultAgent) return defaultAgent[0];
|
|
104
|
+
} catch { /* fall through */ }
|
|
105
|
+
|
|
106
|
+
return "default";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Test-only: reset cache
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/** @internal — test-only; clears the profiles cache. */
|
|
114
|
+
export function _resetProfilesCacheForTesting(): void {
|
|
115
|
+
profilesCache = null;
|
|
116
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderTemplate } from "./template.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// renderTemplate
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
describe("renderTemplate", () => {
|
|
9
|
+
it("replaces a single variable", () => {
|
|
10
|
+
const result = renderTemplate("Hello {{name}}!", { name: "World" });
|
|
11
|
+
expect(result).toBe("Hello World!");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("replaces multiple different variables", () => {
|
|
15
|
+
const template = "Issue {{identifier}}: {{title}} ({{status}})";
|
|
16
|
+
const result = renderTemplate(template, {
|
|
17
|
+
identifier: "ENG-123",
|
|
18
|
+
title: "Fix bug",
|
|
19
|
+
status: "In Progress",
|
|
20
|
+
});
|
|
21
|
+
expect(result).toBe("Issue ENG-123: Fix bug (In Progress)");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("replaces all occurrences of the same variable", () => {
|
|
25
|
+
const template = "{{name}} said hello to {{name}}";
|
|
26
|
+
const result = renderTemplate(template, { name: "Alice" });
|
|
27
|
+
expect(result).toBe("Alice said hello to Alice");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("leaves unmatched placeholders intact", () => {
|
|
31
|
+
const result = renderTemplate("Hello {{name}} and {{other}}", { name: "World" });
|
|
32
|
+
expect(result).toBe("Hello World and {{other}}");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("handles empty vars object", () => {
|
|
36
|
+
const result = renderTemplate("Hello {{name}}!", {});
|
|
37
|
+
expect(result).toBe("Hello {{name}}!");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("replaces with empty string when value is empty", () => {
|
|
41
|
+
const result = renderTemplate("Hello {{name}}!", { name: "" });
|
|
42
|
+
expect(result).toBe("Hello !");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("handles template with no placeholders", () => {
|
|
46
|
+
const result = renderTemplate("No variables here", { name: "World" });
|
|
47
|
+
expect(result).toBe("No variables here");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("handles empty template", () => {
|
|
51
|
+
const result = renderTemplate("", { name: "World" });
|
|
52
|
+
expect(result).toBe("");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("handles null/undefined values gracefully", () => {
|
|
56
|
+
const result = renderTemplate("Value: {{key}}", { key: undefined as unknown as string });
|
|
57
|
+
expect(result).toBe("Value: ");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("works with multiline templates", () => {
|
|
61
|
+
const template = "Issue: {{identifier}}\nTitle: {{title}}\nWorktree: {{worktreePath}}";
|
|
62
|
+
const result = renderTemplate(template, {
|
|
63
|
+
identifier: "CT-42",
|
|
64
|
+
title: "Implement auth",
|
|
65
|
+
worktreePath: "/home/claw/worktrees/ct-42",
|
|
66
|
+
});
|
|
67
|
+
expect(result).toBe("Issue: CT-42\nTitle: Implement auth\nWorktree: /home/claw/worktrees/ct-42");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles special regex characters in values", () => {
|
|
71
|
+
// Note: replaceAll treats $$ as an escape for a literal $ in the replacement.
|
|
72
|
+
// This is standard JS behavior (not a bug). Values with $ may be altered.
|
|
73
|
+
const result = renderTemplate("Pattern: {{pattern}}", {
|
|
74
|
+
pattern: "hello.world+test",
|
|
75
|
+
});
|
|
76
|
+
expect(result).toBe("Pattern: hello.world+test");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("handles variables with numeric values as strings", () => {
|
|
80
|
+
const result = renderTemplate("Attempt {{attempt}} of {{max}}", {
|
|
81
|
+
attempt: "3",
|
|
82
|
+
max: "5",
|
|
83
|
+
});
|
|
84
|
+
expect(result).toBe("Attempt 3 of 5");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* template.ts — Shared template renderer for {{key}} interpolation.
|
|
3
|
+
*
|
|
4
|
+
* Used by pipeline.ts and planner.ts to render prompt templates with
|
|
5
|
+
* issue context variables.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Replaces all {{key}} occurrences in `template` with corresponding values
|
|
10
|
+
* from `vars`. Missing keys are replaced with empty string.
|
|
11
|
+
*/
|
|
12
|
+
export function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
13
|
+
let result = template;
|
|
14
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
15
|
+
result = result.replaceAll(`{{${key}}}`, value ?? "");
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
isValidIssueId,
|
|
4
|
+
isValidUuid,
|
|
5
|
+
isValidTeamId,
|
|
6
|
+
sanitizeForPrompt,
|
|
7
|
+
} from "./validation.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// isValidUuid
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
describe("isValidUuid", () => {
|
|
14
|
+
it("accepts valid lowercase UUID", () => {
|
|
15
|
+
expect(isValidUuid("08cba264-d774-4afd-bc93-ee8213d12ef8")).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("accepts valid uppercase UUID", () => {
|
|
19
|
+
expect(isValidUuid("08CBA264-D774-4AFD-BC93-EE8213D12EF8")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("accepts mixed-case UUID", () => {
|
|
23
|
+
expect(isValidUuid("08CbA264-d774-4aFd-Bc93-ee8213D12ef8")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("rejects short string", () => {
|
|
27
|
+
expect(isValidUuid("abc-123")).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("rejects empty string", () => {
|
|
31
|
+
expect(isValidUuid("")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects UUID without dashes", () => {
|
|
35
|
+
expect(isValidUuid("08cba264d7744afdbc93ee8213d12ef8")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects UUID with extra chars", () => {
|
|
39
|
+
expect(isValidUuid("08cba264-d774-4afd-bc93-ee8213d12ef8x")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("rejects non-hex characters", () => {
|
|
43
|
+
expect(isValidUuid("08cba264-d774-4afd-bc93-ee8213d12xyz")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// isValidIssueId
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
describe("isValidIssueId", () => {
|
|
52
|
+
it("accepts standard Linear identifier like ENG-123", () => {
|
|
53
|
+
expect(isValidIssueId("ENG-123")).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("accepts lowercase Linear identifier like eng-456", () => {
|
|
57
|
+
expect(isValidIssueId("eng-456")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("accepts single-letter prefix like A-1", () => {
|
|
61
|
+
expect(isValidIssueId("A-1")).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("accepts long prefix like PROJECT-99999", () => {
|
|
65
|
+
expect(isValidIssueId("PROJECT-99999")).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("accepts valid UUID", () => {
|
|
69
|
+
expect(isValidIssueId("08cba264-d774-4afd-bc93-ee8213d12ef8")).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects empty string", () => {
|
|
73
|
+
expect(isValidIssueId("")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects plain number", () => {
|
|
77
|
+
expect(isValidIssueId("12345")).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects string with spaces", () => {
|
|
81
|
+
expect(isValidIssueId("ENG 123")).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects identifier starting with number", () => {
|
|
85
|
+
expect(isValidIssueId("123-ABC")).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects identifier with only prefix (no number)", () => {
|
|
89
|
+
expect(isValidIssueId("ENG-")).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("rejects identifier with only number (no prefix)", () => {
|
|
93
|
+
expect(isValidIssueId("-123")).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("rejects random text", () => {
|
|
97
|
+
expect(isValidIssueId("not an issue id")).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("rejects identifier with special chars", () => {
|
|
101
|
+
expect(isValidIssueId("ENG-123!")).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// isValidTeamId
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe("isValidTeamId", () => {
|
|
110
|
+
it("accepts valid UUID", () => {
|
|
111
|
+
expect(isValidTeamId("08cba264-d774-4afd-bc93-ee8213d12ef8")).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects non-UUID", () => {
|
|
115
|
+
expect(isValidTeamId("team-1")).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rejects empty string", () => {
|
|
119
|
+
expect(isValidTeamId("")).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// sanitizeForPrompt
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
describe("sanitizeForPrompt", () => {
|
|
128
|
+
it("escapes template variables", () => {
|
|
129
|
+
const result = sanitizeForPrompt("Hello {{name}} world");
|
|
130
|
+
expect(result).toBe("Hello { {escaped} } world");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("escapes multiple template variables", () => {
|
|
134
|
+
const result = sanitizeForPrompt("{{a}} and {{b}} and {{c}}");
|
|
135
|
+
expect(result).toBe("{ {escaped} } and { {escaped} } and { {escaped} }");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("truncates to maxLength", () => {
|
|
139
|
+
const long = "x".repeat(5000);
|
|
140
|
+
const result = sanitizeForPrompt(long, 100);
|
|
141
|
+
expect(result.length).toBe(100);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("uses default maxLength of 4000", () => {
|
|
145
|
+
const long = "x".repeat(5000);
|
|
146
|
+
const result = sanitizeForPrompt(long);
|
|
147
|
+
expect(result.length).toBe(4000);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns text unchanged when no templates and under limit", () => {
|
|
151
|
+
const result = sanitizeForPrompt("Normal text with no templates");
|
|
152
|
+
expect(result).toBe("Normal text with no templates");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("handles empty string", () => {
|
|
156
|
+
const result = sanitizeForPrompt("");
|
|
157
|
+
expect(result).toBe("");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("escapes before truncating (template at boundary)", () => {
|
|
161
|
+
// Template at position that would be cut
|
|
162
|
+
const text = "x".repeat(3998) + "{{y}}";
|
|
163
|
+
const result = sanitizeForPrompt(text, 4000);
|
|
164
|
+
// After escaping {{y}} → { {escaped} }, string becomes longer,
|
|
165
|
+
// then truncation to 4000 chars applies
|
|
166
|
+
expect(result.length).toBe(4000);
|
|
167
|
+
expect(result).not.toContain("{{");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("handles nested-looking braces", () => {
|
|
171
|
+
const result = sanitizeForPrompt("{{{{deep}}}}");
|
|
172
|
+
// The outer {{...}} matches "{{deep}}" first, inner {{ and }} are left
|
|
173
|
+
expect(result).not.toContain("{{deep}}");
|
|
174
|
+
});
|
|
175
|
+
});
|