@calltelemetry/openclaw-linear 0.7.0 → 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/LICENSE +21 -0
- package/README.md +719 -539
- package/index.ts +40 -1
- package/openclaw.plugin.json +4 -4
- package/package.json +2 -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 +586 -0
- package/src/api/linear-api.ts +50 -11
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +273 -30
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +28 -23
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +114 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +137 -26
- package/src/infra/observability.test.ts +85 -0
- package/src/infra/observability.ts +48 -0
- package/src/infra/resilience.test.ts +94 -0
- package/src/infra/resilience.ts +101 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/artifacts.ts +38 -2
- package/src/pipeline/dag-dispatch.test.ts +553 -0
- package/src/pipeline/dag-dispatch.ts +390 -0
- package/src/pipeline/dispatch-service.ts +48 -1
- package/src/pipeline/dispatch-state.ts +3 -42
- 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 +132 -29
- package/src/pipeline/planner.test.ts +1 -1
- package/src/pipeline/planner.ts +18 -31
- package/src/pipeline/planning-state.ts +2 -40
- package/src/pipeline/tier-assess.test.ts +264 -0
- package/src/pipeline/webhook.ts +134 -36
- 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
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
package/index.ts
CHANGED
|
@@ -7,11 +7,15 @@ import { handleLinearWebhook } from "./src/pipeline/webhook.js";
|
|
|
7
7
|
import { handleOAuthCallback } from "./src/api/oauth-callback.js";
|
|
8
8
|
import { LinearAgentApi, resolveLinearToken } from "./src/api/linear-api.js";
|
|
9
9
|
import { createDispatchService } from "./src/pipeline/dispatch-service.js";
|
|
10
|
+
import { registerDispatchMethods } from "./src/gateway/dispatch-methods.js";
|
|
10
11
|
import { readDispatchState, lookupSessionMapping, getActiveDispatch } from "./src/pipeline/dispatch-state.js";
|
|
11
12
|
import { triggerAudit, processVerdict, type HookContext } from "./src/pipeline/pipeline.js";
|
|
12
13
|
import { createNotifierFromConfig, type NotifyFn } from "./src/infra/notify.js";
|
|
13
14
|
import { readPlanningState, setPlanningCache } from "./src/pipeline/planning-state.js";
|
|
14
15
|
import { createPlannerTools } from "./src/tools/planner-tools.js";
|
|
16
|
+
import { registerDispatchCommands } from "./src/infra/commands.js";
|
|
17
|
+
import { createDispatchHistoryTool } from "./src/tools/dispatch-history-tool.js";
|
|
18
|
+
import { readDispatchState as readStateForHook, listActiveDispatches as listActiveForHook } from "./src/pipeline/dispatch-state.js";
|
|
15
19
|
|
|
16
20
|
export default function register(api: OpenClawPluginApi) {
|
|
17
21
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
@@ -41,6 +45,12 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
41
45
|
// Register planner tools (context injected at runtime via setActivePlannerContext)
|
|
42
46
|
api.registerTool(() => createPlannerTools());
|
|
43
47
|
|
|
48
|
+
// Register dispatch_history tool for agent context
|
|
49
|
+
api.registerTool(() => createDispatchHistoryTool(api, pluginConfig));
|
|
50
|
+
|
|
51
|
+
// Register zero-LLM slash commands for dispatch ops
|
|
52
|
+
registerDispatchCommands(api);
|
|
53
|
+
|
|
44
54
|
// Register Linear webhook handler on a dedicated route
|
|
45
55
|
api.registerHttpRoute({
|
|
46
56
|
path: "/linear/webhook",
|
|
@@ -68,6 +78,9 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
68
78
|
// Register dispatch monitor service (stale detection, session hydration, cleanup)
|
|
69
79
|
api.registerService(createDispatchService(api));
|
|
70
80
|
|
|
81
|
+
// Register dispatch gateway RPC methods (list, get, retry, escalate, cancel, stats)
|
|
82
|
+
registerDispatchMethods(api);
|
|
83
|
+
|
|
71
84
|
// Hydrate planning state on startup
|
|
72
85
|
readPlanningState(pluginConfig?.planningStatePath as string | undefined).then((state) => {
|
|
73
86
|
for (const session of Object.values(state.sessions)) {
|
|
@@ -83,7 +96,7 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
83
96
|
// ---------------------------------------------------------------------------
|
|
84
97
|
|
|
85
98
|
// Instantiate notifier (Discord, Slack, or both — config-driven)
|
|
86
|
-
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime);
|
|
99
|
+
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
87
100
|
|
|
88
101
|
// Register agent_end hook — safety net for sessions_spawn sub-agents.
|
|
89
102
|
// In the current implementation, the worker→audit→verdict flow runs inline
|
|
@@ -159,6 +172,32 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
159
172
|
}
|
|
160
173
|
});
|
|
161
174
|
|
|
175
|
+
// Inject recent dispatch history as context for worker/audit agents
|
|
176
|
+
api.on("before_agent_start", async (event: any, ctx: any) => {
|
|
177
|
+
try {
|
|
178
|
+
const sessionKey = ctx?.sessionKey ?? "";
|
|
179
|
+
if (!sessionKey.startsWith("linear-worker-") && !sessionKey.startsWith("linear-audit-")) return;
|
|
180
|
+
|
|
181
|
+
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
182
|
+
const state = await readStateForHook(statePath);
|
|
183
|
+
const active = listActiveForHook(state);
|
|
184
|
+
|
|
185
|
+
// Include up to 3 recent active dispatches as context
|
|
186
|
+
const recent = active.slice(0, 3);
|
|
187
|
+
if (recent.length === 0) return;
|
|
188
|
+
|
|
189
|
+
const lines = recent.map(d =>
|
|
190
|
+
`- **${d.issueIdentifier}** (${d.tier}): ${d.status}, attempt ${d.attempt}`
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
prependContext: `<dispatch-history>\nActive dispatches:\n${lines.join("\n")}\n</dispatch-history>\n\n`,
|
|
195
|
+
};
|
|
196
|
+
} catch {
|
|
197
|
+
// Never block agent start for telemetry
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
162
201
|
// Narration Guard: catch short "Let me explore..." responses that narrate intent
|
|
163
202
|
// without actually calling tools, and append a warning for the user.
|
|
164
203
|
const NARRATION_PATTERNS = [
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-linear",
|
|
3
3
|
"name": "Linear Agent",
|
|
4
4
|
"description": "Linear integration with OAuth support, agent pipeline, and webhook-driven AI agent lifecycle",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.8.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
@@ -15,10 +15,9 @@
|
|
|
15
15
|
"defaultAgentId": { "type": "string", "description": "OpenClaw agent ID to use for pipeline stages" },
|
|
16
16
|
"enableAudit": { "type": "boolean", "description": "Run auditor stage after implementation", "default": true },
|
|
17
17
|
"codexBaseRepo": { "type": "string", "description": "Path to git repo for Codex worktrees", "default": "/home/claw/ai-workspace" },
|
|
18
|
-
"codexModel": { "type": "string", "description": "Default Codex model (optional — uses Codex default if omitted)" },
|
|
19
|
-
"codexTimeoutMs": { "type": "number", "description": "Default Codex timeout in milliseconds", "default": 600000 },
|
|
20
18
|
"enableOrchestration": { "type": "boolean", "description": "Allow agents to spawn sub-agents via spawn_agent/ask_agent tools", "default": true },
|
|
21
19
|
"worktreeBaseDir": { "type": "string", "description": "Base directory for persistent git worktrees (default: ~/.openclaw/worktrees)" },
|
|
20
|
+
"repos": { "type": "object", "description": "Multi-repo map (name → path, e.g. {\"api\": \"/home/claw/api\", \"frontend\": \"/home/claw/frontend\"})", "additionalProperties": { "type": "string" } },
|
|
22
21
|
"dispatchStatePath": { "type": "string", "description": "Path to dispatch state JSON file (default: ~/.openclaw/linear-dispatch-state.json)" },
|
|
23
22
|
"planningStatePath": { "type": "string", "description": "Path to planning state JSON file (default: ~/.openclaw/linear-planning-state.json)" },
|
|
24
23
|
"notifications": {
|
|
@@ -51,7 +50,8 @@
|
|
|
51
50
|
"stuck": { "type": "boolean" },
|
|
52
51
|
"watchdog_kill": { "type": "boolean" }
|
|
53
52
|
}
|
|
54
|
-
}
|
|
53
|
+
},
|
|
54
|
+
"richFormat": { "type": "boolean", "description": "Send rich embeds (Discord) and HTML (Telegram) instead of plain text", "default": false }
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
57
|
"promptsPath": { "type": "string", "description": "Override path for prompts.yaml (default: ships with plugin)" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calltelemetry/openclaw-linear",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
]
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
+
"cockatiel": "^3.2.1",
|
|
48
49
|
"yaml": "^2.8.2"
|
|
49
50
|
}
|
|
50
51
|
}
|
package/prompts.yaml
CHANGED
|
@@ -28,10 +28,13 @@ worker:
|
|
|
28
28
|
|
|
29
29
|
Instructions:
|
|
30
30
|
1. Read the issue body carefully — it defines what needs to be done
|
|
31
|
-
2.
|
|
32
|
-
3.
|
|
33
|
-
4.
|
|
34
|
-
5.
|
|
31
|
+
2. If the description is vague or missing, implement a reasonable interpretation and note your assumptions
|
|
32
|
+
3. Plan your approach
|
|
33
|
+
4. Implement the solution in the worktree
|
|
34
|
+
5. Run tests to verify your changes
|
|
35
|
+
6. If tests fail, diagnose and fix the failures before returning — do not return with failing tests unless you've exhausted your ability to fix them
|
|
36
|
+
7. Commit your work with a clear commit message
|
|
37
|
+
8. Return a text summary: what you changed, what tests passed, any assumptions you made, and any open questions
|
|
35
38
|
|
|
36
39
|
Your text output will be captured automatically. Do NOT use linearis or attempt to post comments.
|
|
37
40
|
|
|
@@ -67,6 +70,9 @@ audit:
|
|
|
67
70
|
- Post your audit findings as a comment: `linearis comments create {{identifier}} --body "..."`
|
|
68
71
|
- If PASS: update status: `linearis issues update {{identifier}} --status "Done"`
|
|
69
72
|
|
|
73
|
+
When posting your audit comment, include a brief assessment: what was done well,
|
|
74
|
+
what the gaps are (if any), and what the user should look at next.
|
|
75
|
+
|
|
70
76
|
You MUST return a JSON verdict as the last line of your response:
|
|
71
77
|
{"pass": true/false, "criteria": ["list of criteria found"], "gaps": ["list of unmet criteria"], "testResults": "summary of test output"}
|
|
72
78
|
|
|
@@ -75,7 +81,8 @@ rework:
|
|
|
75
81
|
PREVIOUS AUDIT FAILED (attempt {{attempt}}). The auditor found these gaps:
|
|
76
82
|
{{gaps}}
|
|
77
83
|
|
|
78
|
-
Address these specific issues in your rework. Focus on the gaps listed above.
|
|
84
|
+
Address these specific issues in your rework. Focus ONLY on the gaps listed above.
|
|
85
|
+
Do NOT undo or rewrite parts that already work — preserve correct code from prior attempts.
|
|
79
86
|
Remember: you do NOT have linearis access. Just fix the code and return a text summary.
|
|
80
87
|
|
|
81
88
|
planner:
|
|
@@ -97,6 +104,8 @@ planner:
|
|
|
97
104
|
- After each user response, create or update issues to capture what you learned.
|
|
98
105
|
- Briefly summarize what you added before asking your next question.
|
|
99
106
|
- When the plan feels complete, invite the user to say "finalize plan".
|
|
107
|
+
- If the user is vague ("make it better", "you decide"), propose concrete options and ask them to pick.
|
|
108
|
+
- If you've gathered enough info after several turns with no new details, suggest: "This looks ready — say **finalize plan** when you're happy with it."
|
|
100
109
|
|
|
101
110
|
interview: |
|
|
102
111
|
## Project: {{projectName}} ({{rootIdentifier}})
|
|
@@ -123,4 +132,9 @@ planner:
|
|
|
123
132
|
features you want to build, then structure everything into Linear issues with proper
|
|
124
133
|
epic hierarchy and dependency chains.
|
|
125
134
|
|
|
135
|
+
**How this works:**
|
|
136
|
+
- I'll ask questions one at a time and create issues as we go
|
|
137
|
+
- When you're happy with the plan, say **"finalize plan"** — I'll validate it and start dispatching
|
|
138
|
+
- If you want to stop, say **"abandon planning"**
|
|
139
|
+
|
|
126
140
|
Let's start — what is this project about, and what are the main feature areas?
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory functions for Linear GraphQL response shapes.
|
|
3
|
+
*
|
|
4
|
+
* Matches the return types of LinearAgentApi methods.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function makeIssueDetails(overrides?: Record<string, unknown>) {
|
|
8
|
+
return {
|
|
9
|
+
id: "issue-1",
|
|
10
|
+
identifier: "ENG-123",
|
|
11
|
+
title: "Fix webhook routing",
|
|
12
|
+
description: "The webhook handler needs fixing.",
|
|
13
|
+
estimate: 3,
|
|
14
|
+
state: { name: "In Progress" },
|
|
15
|
+
assignee: { name: "Agent" },
|
|
16
|
+
labels: { nodes: [] as Array<{ id: string; name: string }> },
|
|
17
|
+
team: { id: "team-1", name: "Engineering", issueEstimationType: "notUsed" },
|
|
18
|
+
comments: { nodes: [] as Array<{ body: string; user: { name: string } | null; createdAt: string }> },
|
|
19
|
+
project: null as { id: string; name: string } | null,
|
|
20
|
+
parent: null as { id: string; identifier: string } | null,
|
|
21
|
+
relations: { nodes: [] as Array<{ type: string; relatedIssue: { id: string; identifier: string; title: string } }> },
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function makeProjectIssue(
|
|
27
|
+
identifier: string,
|
|
28
|
+
opts?: {
|
|
29
|
+
title?: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
estimate?: number;
|
|
32
|
+
priority?: number;
|
|
33
|
+
state?: { name: string; type: string };
|
|
34
|
+
parentIdentifier?: string;
|
|
35
|
+
labels?: string[];
|
|
36
|
+
relations?: Array<{ type: string; relatedIdentifier: string; relatedTitle?: string }>;
|
|
37
|
+
},
|
|
38
|
+
) {
|
|
39
|
+
return {
|
|
40
|
+
id: `id-${identifier}`,
|
|
41
|
+
identifier,
|
|
42
|
+
title: opts?.title ?? `Issue ${identifier}`,
|
|
43
|
+
description: opts?.description ?? null,
|
|
44
|
+
estimate: opts?.estimate ?? null,
|
|
45
|
+
priority: opts?.priority ?? 0,
|
|
46
|
+
state: opts?.state ?? { name: "Backlog", type: "backlog" },
|
|
47
|
+
parent: opts?.parentIdentifier
|
|
48
|
+
? { id: `id-${opts.parentIdentifier}`, identifier: opts.parentIdentifier }
|
|
49
|
+
: null,
|
|
50
|
+
labels: {
|
|
51
|
+
nodes: (opts?.labels ?? []).map((name) => ({ id: `label-${name}`, name })),
|
|
52
|
+
},
|
|
53
|
+
relations: {
|
|
54
|
+
nodes: (opts?.relations ?? []).map((r) => ({
|
|
55
|
+
type: r.type,
|
|
56
|
+
relatedIssue: {
|
|
57
|
+
id: `id-${r.relatedIdentifier}`,
|
|
58
|
+
identifier: r.relatedIdentifier,
|
|
59
|
+
title: r.relatedTitle ?? `Issue ${r.relatedIdentifier}`,
|
|
60
|
+
},
|
|
61
|
+
})),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function makeProject(overrides?: Record<string, unknown>) {
|
|
67
|
+
return {
|
|
68
|
+
id: "proj-1",
|
|
69
|
+
name: "Test Project",
|
|
70
|
+
description: "A test project",
|
|
71
|
+
state: "started",
|
|
72
|
+
teams: { nodes: [{ id: "team-1", name: "Engineering" }] },
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory functions for Linear webhook event payloads.
|
|
3
|
+
*
|
|
4
|
+
* Matches the shapes received at /linear/webhook from both
|
|
5
|
+
* workspace webhooks and OAuth app webhooks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function makeAgentSessionCreated(overrides?: Record<string, unknown>) {
|
|
9
|
+
return {
|
|
10
|
+
type: "AgentSession",
|
|
11
|
+
action: "create",
|
|
12
|
+
data: {
|
|
13
|
+
id: "sess-1",
|
|
14
|
+
context: { commentBody: "Please investigate this issue" },
|
|
15
|
+
},
|
|
16
|
+
issue: {
|
|
17
|
+
id: "issue-1",
|
|
18
|
+
identifier: "ENG-123",
|
|
19
|
+
title: "Fix webhook routing",
|
|
20
|
+
},
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function makeAgentSessionPrompted(overrides?: Record<string, unknown>) {
|
|
26
|
+
return {
|
|
27
|
+
type: "AgentSession",
|
|
28
|
+
action: "prompted",
|
|
29
|
+
data: {
|
|
30
|
+
id: "sess-prompted",
|
|
31
|
+
context: { prompt: "Looks good, approved!" },
|
|
32
|
+
},
|
|
33
|
+
issue: {
|
|
34
|
+
id: "issue-2",
|
|
35
|
+
identifier: "ENG-124",
|
|
36
|
+
title: "Approved issue",
|
|
37
|
+
},
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function makeCommentCreate(overrides?: Record<string, unknown>) {
|
|
43
|
+
return {
|
|
44
|
+
type: "Comment",
|
|
45
|
+
action: "create",
|
|
46
|
+
data: {
|
|
47
|
+
id: "comment-1",
|
|
48
|
+
body: "This needs work",
|
|
49
|
+
user: { id: "user-1", name: "Test User" },
|
|
50
|
+
issue: {
|
|
51
|
+
id: "issue-1",
|
|
52
|
+
identifier: "ENG-123",
|
|
53
|
+
title: "Fix webhook routing",
|
|
54
|
+
team: { id: "team-1" },
|
|
55
|
+
assignee: { id: "viewer-1" },
|
|
56
|
+
project: null,
|
|
57
|
+
},
|
|
58
|
+
createdAt: new Date().toISOString(),
|
|
59
|
+
},
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function makeIssueUpdate(overrides?: Record<string, unknown>) {
|
|
65
|
+
return {
|
|
66
|
+
type: "Issue",
|
|
67
|
+
action: "update",
|
|
68
|
+
data: {
|
|
69
|
+
id: "issue-1",
|
|
70
|
+
identifier: "ENG-123",
|
|
71
|
+
title: "Fix webhook routing",
|
|
72
|
+
state: { name: "In Progress", type: "started" },
|
|
73
|
+
assignee: { id: "viewer-1", name: "Agent" },
|
|
74
|
+
team: { id: "team-1" },
|
|
75
|
+
project: null,
|
|
76
|
+
},
|
|
77
|
+
...overrides,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function makeIssueCreate(overrides?: Record<string, unknown>) {
|
|
82
|
+
return {
|
|
83
|
+
type: "Issue",
|
|
84
|
+
action: "create",
|
|
85
|
+
data: {
|
|
86
|
+
id: "issue-new",
|
|
87
|
+
identifier: "ENG-200",
|
|
88
|
+
title: "New issue",
|
|
89
|
+
state: { name: "Backlog", type: "backlog" },
|
|
90
|
+
assignee: null,
|
|
91
|
+
team: { id: "team-1" },
|
|
92
|
+
project: null,
|
|
93
|
+
},
|
|
94
|
+
...overrides,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function makeAppUserNotification(overrides?: Record<string, unknown>) {
|
|
99
|
+
return {
|
|
100
|
+
type: "AppUserNotification",
|
|
101
|
+
action: "create",
|
|
102
|
+
data: {
|
|
103
|
+
type: "issueAssigned",
|
|
104
|
+
issue: {
|
|
105
|
+
id: "issue-1",
|
|
106
|
+
identifier: "ENG-123",
|
|
107
|
+
title: "Fix webhook routing",
|
|
108
|
+
},
|
|
109
|
+
user: { id: "user-1", name: "Test User" },
|
|
110
|
+
},
|
|
111
|
+
...overrides,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test helpers for E2E and integration tests.
|
|
3
|
+
*
|
|
4
|
+
* Provides reusable mock factories that consolidate patterns scattered across
|
|
5
|
+
* unit test files. Existing unit tests keep their local factories; new E2E
|
|
6
|
+
* tests use these from the start.
|
|
7
|
+
*/
|
|
8
|
+
import { vi } from "vitest";
|
|
9
|
+
import { mkdtempSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
13
|
+
import type { HookContext } from "../pipeline/pipeline.js";
|
|
14
|
+
import type { NotifyFn } from "../infra/notify.js";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Mock OpenClaw Plugin API
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export function createMockApi(overrides?: Partial<{
|
|
21
|
+
logger: Partial<OpenClawPluginApi["logger"]>;
|
|
22
|
+
pluginConfig: Record<string, unknown>;
|
|
23
|
+
runtime: Record<string, unknown>;
|
|
24
|
+
}>): OpenClawPluginApi {
|
|
25
|
+
return {
|
|
26
|
+
logger: {
|
|
27
|
+
info: vi.fn(),
|
|
28
|
+
warn: vi.fn(),
|
|
29
|
+
error: vi.fn(),
|
|
30
|
+
debug: vi.fn(),
|
|
31
|
+
...overrides?.logger,
|
|
32
|
+
},
|
|
33
|
+
pluginConfig: overrides?.pluginConfig ?? {},
|
|
34
|
+
runtime: {
|
|
35
|
+
channel: {
|
|
36
|
+
discord: { sendMessageDiscord: vi.fn().mockResolvedValue(undefined) },
|
|
37
|
+
slack: { sendMessageSlack: vi.fn().mockResolvedValue(undefined) },
|
|
38
|
+
telegram: { sendMessageTelegram: vi.fn().mockResolvedValue(undefined) },
|
|
39
|
+
signal: { sendMessageSignal: vi.fn().mockResolvedValue(undefined) },
|
|
40
|
+
},
|
|
41
|
+
...overrides?.runtime,
|
|
42
|
+
},
|
|
43
|
+
} as unknown as OpenClawPluginApi;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Mock Linear API
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export interface MockLinearApi {
|
|
51
|
+
getIssueDetails: ReturnType<typeof vi.fn>;
|
|
52
|
+
createComment: ReturnType<typeof vi.fn>;
|
|
53
|
+
emitActivity: ReturnType<typeof vi.fn>;
|
|
54
|
+
updateSession: ReturnType<typeof vi.fn>;
|
|
55
|
+
getProject: ReturnType<typeof vi.fn>;
|
|
56
|
+
getProjectIssues: ReturnType<typeof vi.fn>;
|
|
57
|
+
getTeamStates: ReturnType<typeof vi.fn>;
|
|
58
|
+
getTeamLabels: ReturnType<typeof vi.fn>;
|
|
59
|
+
createIssue: ReturnType<typeof vi.fn>;
|
|
60
|
+
updateIssue: ReturnType<typeof vi.fn>;
|
|
61
|
+
updateIssueExtended: ReturnType<typeof vi.fn>;
|
|
62
|
+
createIssueRelation: ReturnType<typeof vi.fn>;
|
|
63
|
+
getViewerId: ReturnType<typeof vi.fn>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createMockLinearApi(overrides?: Partial<MockLinearApi>): MockLinearApi {
|
|
67
|
+
return {
|
|
68
|
+
getIssueDetails: vi.fn().mockResolvedValue(null),
|
|
69
|
+
createComment: vi.fn().mockResolvedValue("comment-id"),
|
|
70
|
+
emitActivity: vi.fn().mockResolvedValue(undefined),
|
|
71
|
+
updateSession: vi.fn().mockResolvedValue(undefined),
|
|
72
|
+
getProject: vi.fn().mockResolvedValue({
|
|
73
|
+
id: "proj-1",
|
|
74
|
+
name: "Test Project",
|
|
75
|
+
description: "",
|
|
76
|
+
state: "started",
|
|
77
|
+
teams: { nodes: [{ id: "team-1", name: "Team" }] },
|
|
78
|
+
}),
|
|
79
|
+
getProjectIssues: vi.fn().mockResolvedValue([]),
|
|
80
|
+
getTeamStates: vi.fn().mockResolvedValue([
|
|
81
|
+
{ id: "st-1", name: "Backlog", type: "backlog" },
|
|
82
|
+
{ id: "st-2", name: "In Progress", type: "started" },
|
|
83
|
+
{ id: "st-3", name: "Done", type: "completed" },
|
|
84
|
+
]),
|
|
85
|
+
getTeamLabels: vi.fn().mockResolvedValue([]),
|
|
86
|
+
createIssue: vi.fn().mockResolvedValue({ id: "new-issue-id", identifier: "PROJ-NEW" }),
|
|
87
|
+
updateIssue: vi.fn().mockResolvedValue(undefined),
|
|
88
|
+
updateIssueExtended: vi.fn().mockResolvedValue(undefined),
|
|
89
|
+
createIssueRelation: vi.fn().mockResolvedValue(undefined),
|
|
90
|
+
getViewerId: vi.fn().mockResolvedValue("viewer-1"),
|
|
91
|
+
...overrides,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Mock HookContext
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export function createMockHookCtx(opts: {
|
|
100
|
+
configPath?: string;
|
|
101
|
+
planningStatePath?: string;
|
|
102
|
+
pluginConfig?: Record<string, unknown>;
|
|
103
|
+
linearApi?: MockLinearApi;
|
|
104
|
+
notify?: NotifyFn;
|
|
105
|
+
}): HookContext {
|
|
106
|
+
const configPath = opts.configPath ?? tmpStatePath("claw-hook-");
|
|
107
|
+
return {
|
|
108
|
+
api: createMockApi({
|
|
109
|
+
pluginConfig: {
|
|
110
|
+
dispatchStatePath: configPath,
|
|
111
|
+
planningStatePath: opts.planningStatePath ?? configPath,
|
|
112
|
+
...opts.pluginConfig,
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
linearApi: (opts.linearApi ?? createMockLinearApi()) as any,
|
|
116
|
+
notify: opts.notify ?? vi.fn().mockResolvedValue(undefined),
|
|
117
|
+
pluginConfig: {
|
|
118
|
+
dispatchStatePath: configPath,
|
|
119
|
+
planningStatePath: opts.planningStatePath ?? configPath,
|
|
120
|
+
...opts.pluginConfig,
|
|
121
|
+
},
|
|
122
|
+
configPath,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Temp path helper
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export function tmpStatePath(prefix = "claw-test-"): string {
|
|
131
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
132
|
+
return join(dir, "state.json");
|
|
133
|
+
}
|
package/src/agent/agent.test.ts
CHANGED
|
@@ -61,6 +61,149 @@ function createApi(): OpenClawPluginApi {
|
|
|
61
61
|
} as unknown as OpenClawPluginApi;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
describe("runAgent subprocess", () => {
|
|
65
|
+
it("extracts text from JSON payloads", async () => {
|
|
66
|
+
const api = createApi();
|
|
67
|
+
(api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
|
|
68
|
+
code: 0,
|
|
69
|
+
stdout: JSON.stringify({ result: { payloads: [{ text: "hello" }, { text: "world" }] } }),
|
|
70
|
+
stderr: "",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const result = await runAgent({
|
|
74
|
+
api,
|
|
75
|
+
agentId: "test-agent",
|
|
76
|
+
sessionId: "session-1",
|
|
77
|
+
message: "do something",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(result.success).toBe(true);
|
|
81
|
+
expect(result.output).toContain("hello");
|
|
82
|
+
expect(result.output).toContain("world");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("uses raw stdout when JSON parsing fails", async () => {
|
|
86
|
+
const api = createApi();
|
|
87
|
+
(api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
|
|
88
|
+
code: 0,
|
|
89
|
+
stdout: "plain text output",
|
|
90
|
+
stderr: "",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const result = await runAgent({
|
|
94
|
+
api,
|
|
95
|
+
agentId: "test-agent",
|
|
96
|
+
sessionId: "session-1",
|
|
97
|
+
message: "do something",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(result.success).toBe(true);
|
|
101
|
+
expect(result.output).toBe("plain text output");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("uses stderr when command fails with no stdout", async () => {
|
|
105
|
+
const api = createApi();
|
|
106
|
+
(api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
|
|
107
|
+
code: 1,
|
|
108
|
+
stdout: "",
|
|
109
|
+
stderr: "error from stderr",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const result = await runAgent({
|
|
113
|
+
api,
|
|
114
|
+
agentId: "test-agent",
|
|
115
|
+
sessionId: "session-1",
|
|
116
|
+
message: "do something",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result.success).toBe(false);
|
|
120
|
+
expect(result.output).toContain("error from stderr");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("includes agentId in command arguments", async () => {
|
|
124
|
+
const api = createApi();
|
|
125
|
+
const runCmd = vi.fn().mockResolvedValue({
|
|
126
|
+
code: 0,
|
|
127
|
+
stdout: "ok",
|
|
128
|
+
stderr: "",
|
|
129
|
+
});
|
|
130
|
+
(api.runtime.system as any).runCommandWithTimeout = runCmd;
|
|
131
|
+
|
|
132
|
+
await runAgent({
|
|
133
|
+
api,
|
|
134
|
+
agentId: "my-agent",
|
|
135
|
+
sessionId: "session-1",
|
|
136
|
+
message: "test",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const args = runCmd.mock.calls[0][0];
|
|
140
|
+
expect(args).toContain("my-agent");
|
|
141
|
+
expect(args).toContain("--agent");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("passes timeout in seconds to subprocess", async () => {
|
|
145
|
+
const api = createApi();
|
|
146
|
+
const runCmd = vi.fn().mockResolvedValue({
|
|
147
|
+
code: 0,
|
|
148
|
+
stdout: "ok",
|
|
149
|
+
stderr: "",
|
|
150
|
+
});
|
|
151
|
+
(api.runtime.system as any).runCommandWithTimeout = runCmd;
|
|
152
|
+
|
|
153
|
+
await runAgent({
|
|
154
|
+
api,
|
|
155
|
+
agentId: "test",
|
|
156
|
+
sessionId: "session-1",
|
|
157
|
+
message: "test",
|
|
158
|
+
timeoutMs: 60_000,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const args: string[] = runCmd.mock.calls[0][0];
|
|
162
|
+
const timeoutIdx = args.indexOf("--timeout");
|
|
163
|
+
expect(timeoutIdx).toBeGreaterThan(-1);
|
|
164
|
+
expect(args[timeoutIdx + 1]).toBe("60");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("handles empty payloads array", async () => {
|
|
168
|
+
const api = createApi();
|
|
169
|
+
(api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
|
|
170
|
+
code: 0,
|
|
171
|
+
stdout: JSON.stringify({ result: { payloads: [] } }),
|
|
172
|
+
stderr: "",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const result = await runAgent({
|
|
176
|
+
api,
|
|
177
|
+
agentId: "test",
|
|
178
|
+
sessionId: "s1",
|
|
179
|
+
message: "test",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(result.success).toBe(true);
|
|
183
|
+
// Falls back to raw stdout when no payload text
|
|
184
|
+
expect(result.output).toBeTruthy();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("handles null payloads text", async () => {
|
|
188
|
+
const api = createApi();
|
|
189
|
+
(api.runtime.system as any).runCommandWithTimeout = vi.fn().mockResolvedValue({
|
|
190
|
+
code: 0,
|
|
191
|
+
stdout: JSON.stringify({ result: { payloads: [{ text: null }, { text: "real" }] } }),
|
|
192
|
+
stderr: "",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const result = await runAgent({
|
|
196
|
+
api,
|
|
197
|
+
agentId: "test",
|
|
198
|
+
sessionId: "s1",
|
|
199
|
+
message: "test",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(result.success).toBe(true);
|
|
203
|
+
expect(result.output).toContain("real");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
64
207
|
describe("runAgent retry wrapper", () => {
|
|
65
208
|
it("returns success on first attempt when no watchdog kill", async () => {
|
|
66
209
|
const api = createApi();
|