@calltelemetry/openclaw-linear 0.7.0 → 0.7.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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * dispatch-history-tool.ts — Agent tool for searching dispatch history.
3
+ *
4
+ * Searches dispatch state + memory files to provide context about
5
+ * past dispatches. Useful for agents to understand what work has
6
+ * been done on related issues.
7
+ */
8
+ import { readdirSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
11
+ import { jsonResult } from "openclaw/plugin-sdk";
12
+ import { readDispatchState, listActiveDispatches, type ActiveDispatch, type CompletedDispatch } from "../pipeline/dispatch-state.js";
13
+ import { resolveOrchestratorWorkspace } from "../pipeline/artifacts.js";
14
+
15
+ export function createDispatchHistoryTool(
16
+ api: OpenClawPluginApi,
17
+ pluginConfig?: Record<string, unknown>,
18
+ ): AnyAgentTool {
19
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
20
+
21
+ return {
22
+ name: "dispatch_history",
23
+ label: "Dispatch History",
24
+ description:
25
+ "Search dispatch history for past and active Linear issue dispatches. " +
26
+ "Returns issue identifier, tier, status, attempts, and summary excerpts.",
27
+ parameters: {
28
+ type: "object",
29
+ properties: {
30
+ query: {
31
+ type: "string",
32
+ description: "Issue identifier (e.g. 'CT-123') or keyword to search in summaries.",
33
+ },
34
+ tier: {
35
+ type: "string",
36
+ enum: ["junior", "medior", "senior"],
37
+ description: "Filter by tier.",
38
+ },
39
+ status: {
40
+ type: "string",
41
+ enum: ["dispatched", "working", "auditing", "done", "failed", "stuck"],
42
+ description: "Filter by status.",
43
+ },
44
+ limit: {
45
+ type: "number",
46
+ description: "Max results to return (default: 10).",
47
+ },
48
+ },
49
+ },
50
+ execute: async (_toolCallId: string, params: {
51
+ query?: string;
52
+ tier?: string;
53
+ status?: string;
54
+ limit?: number;
55
+ }) => {
56
+ const maxResults = params.limit ?? 10;
57
+ const results: Array<{
58
+ identifier: string;
59
+ tier: string;
60
+ status: string;
61
+ attempts: number;
62
+ summary?: string;
63
+ active: boolean;
64
+ }> = [];
65
+
66
+ // Search active dispatches
67
+ const state = await readDispatchState(statePath);
68
+ const active = listActiveDispatches(state);
69
+ for (const d of active) {
70
+ if (matchesFilters(d.issueIdentifier, d.tier, d.status, params)) {
71
+ results.push({
72
+ identifier: d.issueIdentifier,
73
+ tier: d.tier,
74
+ status: d.status,
75
+ attempts: d.attempt,
76
+ active: true,
77
+ });
78
+ }
79
+ }
80
+
81
+ // Search completed dispatches
82
+ for (const [id, d] of Object.entries(state.dispatches.completed)) {
83
+ if (matchesFilters(id, d.tier, d.status, params)) {
84
+ results.push({
85
+ identifier: id,
86
+ tier: d.tier,
87
+ status: d.status,
88
+ attempts: d.totalAttempts ?? 0,
89
+ active: false,
90
+ });
91
+ }
92
+ }
93
+
94
+ // Search memory files for richer context
95
+ try {
96
+ const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
97
+ const memDir = join(wsDir, "memory");
98
+ const files = readdirSync(memDir).filter(f => f.startsWith("dispatch-") && f.endsWith(".md"));
99
+
100
+ for (const file of files) {
101
+ const id = file.replace("dispatch-", "").replace(".md", "");
102
+ // Skip if already in results
103
+ if (results.some(r => r.identifier === id)) {
104
+ // Enrich with summary
105
+ const existing = results.find(r => r.identifier === id);
106
+ if (existing && !existing.summary) {
107
+ try {
108
+ const content = readFileSync(join(memDir, file), "utf-8");
109
+ existing.summary = extractSummaryExcerpt(content, params.query);
110
+ } catch {}
111
+ }
112
+ continue;
113
+ }
114
+
115
+ // Check if memory file matches query
116
+ if (params.query) {
117
+ try {
118
+ const content = readFileSync(join(memDir, file), "utf-8");
119
+ if (
120
+ id.toLowerCase().includes(params.query.toLowerCase()) ||
121
+ content.toLowerCase().includes(params.query.toLowerCase())
122
+ ) {
123
+ const meta = parseFrontmatter(content);
124
+ if (matchesFilters(id, meta.tier, meta.status, params)) {
125
+ results.push({
126
+ identifier: id,
127
+ tier: meta.tier ?? "unknown",
128
+ status: meta.status ?? "completed",
129
+ attempts: meta.attempts ?? 0,
130
+ summary: extractSummaryExcerpt(content, params.query),
131
+ active: false,
132
+ });
133
+ }
134
+ }
135
+ } catch {}
136
+ }
137
+ }
138
+ } catch {}
139
+
140
+ const limited = results.slice(0, maxResults);
141
+
142
+ if (limited.length === 0) {
143
+ return jsonResult({ message: "No dispatch history found matching the criteria.", results: [] });
144
+ }
145
+
146
+ const formatted = limited.map(r => {
147
+ const parts = [`**${r.identifier}** — ${r.status} (${r.tier}, ${r.attempts} attempts)${r.active ? " [ACTIVE]" : ""}`];
148
+ if (r.summary) parts.push(` ${r.summary}`);
149
+ return parts.join("\n");
150
+ }).join("\n\n");
151
+
152
+ return jsonResult({
153
+ message: `Found ${limited.length} dispatch(es):\n\n${formatted}`,
154
+ results: limited,
155
+ });
156
+ },
157
+ } as unknown as AnyAgentTool;
158
+ }
159
+
160
+ function matchesFilters(
161
+ identifier: string,
162
+ tier: string,
163
+ status: string,
164
+ params: { query?: string; tier?: string; status?: string },
165
+ ): boolean {
166
+ if (params.tier && tier !== params.tier) return false;
167
+ if (params.status && status !== params.status) return false;
168
+ if (params.query && !identifier.toLowerCase().includes(params.query.toLowerCase())) return false;
169
+ return true;
170
+ }
171
+
172
+ function parseFrontmatter(content: string): Record<string, any> {
173
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
174
+ if (!match) return {};
175
+ const result: Record<string, any> = {};
176
+ for (const line of match[1].split("\n")) {
177
+ const [key, ...rest] = line.split(": ");
178
+ if (key && rest.length > 0) {
179
+ let value: any = rest.join(": ").trim();
180
+ if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
181
+ else if (!isNaN(Number(value))) value = Number(value);
182
+ result[key.trim()] = value;
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+
188
+ function extractSummaryExcerpt(content: string, query?: string): string {
189
+ // Remove frontmatter
190
+ const body = content.replace(/^---[\s\S]*?---\n?/, "").trim();
191
+ if (!query) return body.slice(0, 200);
192
+
193
+ // Find the query in the body and return surrounding context
194
+ const lower = body.toLowerCase();
195
+ const idx = lower.indexOf(query.toLowerCase());
196
+ if (idx === -1) return body.slice(0, 200);
197
+
198
+ const start = Math.max(0, idx - 50);
199
+ const end = Math.min(body.length, idx + query.length + 150);
200
+ return (start > 0 ? "..." : "") + body.slice(start, end) + (end < body.length ? "..." : "");
201
+ }
@@ -0,0 +1,158 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { createOrchestrationTools } from "./orchestration-tools.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Mock runAgent
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const mockRunAgent = vi.fn();
9
+
10
+ vi.mock("../agent/agent.js", () => ({
11
+ runAgent: (...args: unknown[]) => mockRunAgent(...args),
12
+ }));
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Mock jsonResult (from openclaw/plugin-sdk)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ vi.mock("openclaw/plugin-sdk", () => ({
19
+ jsonResult: (obj: unknown) => ({ type: "json", data: obj }),
20
+ }));
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ function makeApi() {
27
+ return {
28
+ logger: {
29
+ info: vi.fn(),
30
+ warn: vi.fn(),
31
+ error: vi.fn(),
32
+ },
33
+ } as any;
34
+ }
35
+
36
+ function makeCtx(): Record<string, unknown> {
37
+ return {};
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Tests
42
+ // ---------------------------------------------------------------------------
43
+
44
+ describe("createOrchestrationTools", () => {
45
+ beforeEach(() => {
46
+ mockRunAgent.mockReset();
47
+ });
48
+
49
+ it("returns 2 tools", () => {
50
+ const tools = createOrchestrationTools(makeApi(), makeCtx());
51
+
52
+ expect(tools).toHaveLength(2);
53
+ expect(tools[0].name).toBe("spawn_agent");
54
+ expect(tools[1].name).toBe("ask_agent");
55
+ });
56
+
57
+ it("spawn_agent tool has correct name and parameters schema", () => {
58
+ const tools = createOrchestrationTools(makeApi(), makeCtx());
59
+ const spawn = tools[0];
60
+
61
+ expect(spawn.name).toBe("spawn_agent");
62
+ expect(spawn.parameters).toBeDefined();
63
+ expect(spawn.parameters.type).toBe("object");
64
+ expect(spawn.parameters.properties.agentId).toBeDefined();
65
+ expect(spawn.parameters.properties.task).toBeDefined();
66
+ expect(spawn.parameters.properties.timeoutSeconds).toBeDefined();
67
+ expect(spawn.parameters.required).toEqual(["agentId", "task"]);
68
+ });
69
+
70
+ it("spawn_agent dispatches to runAgent and returns immediately", async () => {
71
+ // runAgent returns a promise that never resolves (to prove fire-and-forget)
72
+ let resolveAgent!: (value: unknown) => void;
73
+ const agentPromise = new Promise((resolve) => { resolveAgent = resolve; });
74
+ mockRunAgent.mockReturnValue(agentPromise);
75
+
76
+ const api = makeApi();
77
+ const tools = createOrchestrationTools(api, makeCtx());
78
+ const spawn = tools[0];
79
+
80
+ const result = await spawn.execute("call-1", {
81
+ agentId: "kaylee",
82
+ task: "Investigate database performance",
83
+ });
84
+
85
+ // Should return immediately with a sessionId, even though runAgent hasn't resolved
86
+ expect(result.data.agentId).toBe("kaylee");
87
+ expect(result.data.sessionId).toMatch(/^spawn-kaylee-/);
88
+ expect(result.data.message).toContain("Dispatched task");
89
+ expect(mockRunAgent).toHaveBeenCalledOnce();
90
+
91
+ // Clean up: resolve the hanging promise to avoid unhandled rejection
92
+ resolveAgent({ success: true, output: "done" });
93
+ });
94
+
95
+ it("ask_agent returns response on success", async () => {
96
+ mockRunAgent.mockResolvedValue({
97
+ success: true,
98
+ output: "No, the schema change is backward-compatible and won't break tests.",
99
+ });
100
+
101
+ const api = makeApi();
102
+ const tools = createOrchestrationTools(api, makeCtx());
103
+ const askAgent = tools[1];
104
+
105
+ const result = await askAgent.execute("call-2", {
106
+ agentId: "kaylee",
107
+ message: "Would this schema change break existing tests?",
108
+ });
109
+
110
+ expect(result.data.agentId).toBe("kaylee");
111
+ expect(result.data.response).toBe(
112
+ "No, the schema change is backward-compatible and won't break tests.",
113
+ );
114
+ expect(result.data.message).toContain("Response from agent");
115
+ });
116
+
117
+ it("ask_agent returns error message on failure", async () => {
118
+ mockRunAgent.mockResolvedValue({
119
+ success: false,
120
+ output: "Agent timed out after 120s",
121
+ });
122
+
123
+ const api = makeApi();
124
+ const tools = createOrchestrationTools(api, makeCtx());
125
+ const askAgent = tools[1];
126
+
127
+ const result = await askAgent.execute("call-3", {
128
+ agentId: "kaylee",
129
+ message: "Check if the migration works",
130
+ });
131
+
132
+ expect(result.data.agentId).toBe("kaylee");
133
+ expect(result.data.error).toBe("Agent timed out after 120s");
134
+ expect(result.data.message).toContain("failed to respond");
135
+ expect(result.data.response).toBeUndefined();
136
+ });
137
+
138
+ it("ask_agent uses custom timeout when provided", async () => {
139
+ mockRunAgent.mockResolvedValue({
140
+ success: true,
141
+ output: "Result from agent",
142
+ });
143
+
144
+ const api = makeApi();
145
+ const tools = createOrchestrationTools(api, makeCtx());
146
+ const askAgent = tools[1];
147
+
148
+ await askAgent.execute("call-4", {
149
+ agentId: "mal",
150
+ message: "Run a long analysis",
151
+ timeoutSeconds: 600,
152
+ });
153
+
154
+ const callArgs = mockRunAgent.mock.calls[0][0];
155
+ // 600 seconds = 600_000 ms
156
+ expect(callArgs.timeoutMs).toBe(600_000);
157
+ });
158
+ });