@calltelemetry/openclaw-linear 0.7.1 → 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/README.md +719 -539
- package/index.ts +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -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 +93 -1
- package/src/api/linear-api.ts +37 -1
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/infra/cli.ts +176 -1
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +28 -23
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +29 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +26 -15
- package/src/infra/observability.test.ts +85 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/dispatch-state.ts +1 -0
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +455 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +47 -18
- package/src/pipeline/planner.test.ts +1 -1
- package/src/pipeline/planner.ts +12 -30
- package/src/pipeline/tier-assess.test.ts +89 -0
- package/src/pipeline/webhook.ts +114 -37
- 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/index.ts
CHANGED
|
@@ -96,7 +96,7 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
96
96
|
// ---------------------------------------------------------------------------
|
|
97
97
|
|
|
98
98
|
// Instantiate notifier (Discord, Slack, or both — config-driven)
|
|
99
|
-
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime);
|
|
99
|
+
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
100
100
|
|
|
101
101
|
// Register agent_end hook — safety net for sessions_spawn sub-agents.
|
|
102
102
|
// In the current implementation, the worker→audit→verdict flow runs inline
|
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,
|
package/package.json
CHANGED
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();
|
|
@@ -300,7 +300,7 @@ describe("LinearAgentApi", () => {
|
|
|
300
300
|
});
|
|
301
301
|
|
|
302
302
|
await expect(api.updateIssue("i1", { estimate: 1 })).rejects.toThrow(
|
|
303
|
-
/Linear API
|
|
303
|
+
/Linear API authentication failed/,
|
|
304
304
|
);
|
|
305
305
|
});
|
|
306
306
|
});
|
|
@@ -464,6 +464,98 @@ describe("LinearAgentApi", () => {
|
|
|
464
464
|
});
|
|
465
465
|
});
|
|
466
466
|
|
|
467
|
+
describe("getTeams", () => {
|
|
468
|
+
it("returns parsed team list", async () => {
|
|
469
|
+
fetchMock.mockResolvedValueOnce(
|
|
470
|
+
okResponse({
|
|
471
|
+
teams: {
|
|
472
|
+
nodes: [
|
|
473
|
+
{ id: "t1", name: "Engineering", key: "ENG" },
|
|
474
|
+
{ id: "t2", name: "Design", key: "DES" },
|
|
475
|
+
],
|
|
476
|
+
},
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const api = new LinearAgentApi(TOKEN);
|
|
481
|
+
const teams = await api.getTeams();
|
|
482
|
+
expect(teams).toHaveLength(2);
|
|
483
|
+
expect(teams[0]).toEqual({ id: "t1", name: "Engineering", key: "ENG" });
|
|
484
|
+
expect(teams[1]).toEqual({ id: "t2", name: "Design", key: "DES" });
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("handles empty teams list", async () => {
|
|
488
|
+
fetchMock.mockResolvedValueOnce(okResponse({ teams: { nodes: [] } }));
|
|
489
|
+
|
|
490
|
+
const api = new LinearAgentApi(TOKEN);
|
|
491
|
+
const teams = await api.getTeams();
|
|
492
|
+
expect(teams).toEqual([]);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe("createLabel", () => {
|
|
497
|
+
it("sends correct mutation and returns label", async () => {
|
|
498
|
+
fetchMock.mockResolvedValueOnce(
|
|
499
|
+
okResponse({
|
|
500
|
+
issueLabelCreate: {
|
|
501
|
+
success: true,
|
|
502
|
+
issueLabel: { id: "label-1", name: "repo:api" },
|
|
503
|
+
},
|
|
504
|
+
}),
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
const api = new LinearAgentApi(TOKEN);
|
|
508
|
+
const label = await api.createLabel("t1", "repo:api", {
|
|
509
|
+
color: "#5e6ad2",
|
|
510
|
+
description: "Multi-repo dispatch: api",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
expect(label).toEqual({ id: "label-1", name: "repo:api" });
|
|
514
|
+
|
|
515
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
516
|
+
expect(body.query).toContain("issueLabelCreate");
|
|
517
|
+
expect(body.variables.input).toEqual({
|
|
518
|
+
teamId: "t1",
|
|
519
|
+
name: "repo:api",
|
|
520
|
+
color: "#5e6ad2",
|
|
521
|
+
description: "Multi-repo dispatch: api",
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("throws on API failure", async () => {
|
|
526
|
+
fetchMock.mockResolvedValueOnce(
|
|
527
|
+
okResponse({
|
|
528
|
+
issueLabelCreate: { success: false, issueLabel: null },
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
const api = new LinearAgentApi(TOKEN);
|
|
533
|
+
await expect(
|
|
534
|
+
api.createLabel("t1", "repo:bad"),
|
|
535
|
+
).rejects.toThrow(/Failed to create label/);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("omits optional fields when not provided", async () => {
|
|
539
|
+
fetchMock.mockResolvedValueOnce(
|
|
540
|
+
okResponse({
|
|
541
|
+
issueLabelCreate: {
|
|
542
|
+
success: true,
|
|
543
|
+
issueLabel: { id: "label-2", name: "repo:frontend" },
|
|
544
|
+
},
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
const api = new LinearAgentApi(TOKEN);
|
|
549
|
+
await api.createLabel("t1", "repo:frontend");
|
|
550
|
+
|
|
551
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
552
|
+
expect(body.variables.input).toEqual({
|
|
553
|
+
teamId: "t1",
|
|
554
|
+
name: "repo:frontend",
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
467
559
|
describe("createSessionOnIssue", () => {
|
|
468
560
|
it("returns sessionId on success", async () => {
|
|
469
561
|
fetchMock.mockResolvedValueOnce(
|