@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +719 -539
  3. package/index.ts +40 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +19 -5
  7. package/src/__test__/fixtures/linear-responses.ts +75 -0
  8. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  9. package/src/__test__/helpers.ts +133 -0
  10. package/src/agent/agent.test.ts +143 -0
  11. package/src/api/linear-api.test.ts +586 -0
  12. package/src/api/linear-api.ts +50 -11
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/gateway/dispatch-methods.ts +243 -0
  15. package/src/infra/cli.ts +273 -30
  16. package/src/infra/codex-worktree.ts +83 -0
  17. package/src/infra/commands.test.ts +276 -0
  18. package/src/infra/commands.ts +156 -0
  19. package/src/infra/doctor.test.ts +19 -0
  20. package/src/infra/doctor.ts +28 -23
  21. package/src/infra/file-lock.test.ts +61 -0
  22. package/src/infra/file-lock.ts +49 -0
  23. package/src/infra/multi-repo.test.ts +163 -0
  24. package/src/infra/multi-repo.ts +114 -0
  25. package/src/infra/notify.test.ts +155 -16
  26. package/src/infra/notify.ts +137 -26
  27. package/src/infra/observability.test.ts +85 -0
  28. package/src/infra/observability.ts +48 -0
  29. package/src/infra/resilience.test.ts +94 -0
  30. package/src/infra/resilience.ts +101 -0
  31. package/src/pipeline/artifacts.test.ts +26 -3
  32. package/src/pipeline/artifacts.ts +38 -2
  33. package/src/pipeline/dag-dispatch.test.ts +553 -0
  34. package/src/pipeline/dag-dispatch.ts +390 -0
  35. package/src/pipeline/dispatch-service.ts +48 -1
  36. package/src/pipeline/dispatch-state.ts +3 -42
  37. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  38. package/src/pipeline/e2e-planning.test.ts +455 -0
  39. package/src/pipeline/pipeline.test.ts +69 -0
  40. package/src/pipeline/pipeline.ts +132 -29
  41. package/src/pipeline/planner.test.ts +1 -1
  42. package/src/pipeline/planner.ts +18 -31
  43. package/src/pipeline/planning-state.ts +2 -40
  44. package/src/pipeline/tier-assess.test.ts +264 -0
  45. package/src/pipeline/webhook.ts +134 -36
  46. package/src/tools/cli-shared.test.ts +155 -0
  47. package/src/tools/code-tool.test.ts +210 -0
  48. package/src/tools/dispatch-history-tool.test.ts +315 -0
  49. package/src/tools/dispatch-history-tool.ts +201 -0
  50. 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 = [
@@ -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.7.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.7.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. Plan your approach
32
- 3. Implement the solution in the worktree
33
- 4. Run tests to verify your changes
34
- 5. Return a text summary of what you changed, what tests you ran, and any notes
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
+ }
@@ -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();