@calltelemetry/openclaw-linear 0.7.1 → 0.8.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.
- package/README.md +834 -536
- package/index.ts +1 -1
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
- package/prompts.yaml +46 -6
- 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 +192 -0
- package/src/agent/agent.ts +26 -1
- 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 +30 -25
- 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 +478 -0
- package/src/pipeline/intent-classify.test.ts +285 -0
- package/src/pipeline/intent-classify.ts +259 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +47 -18
- package/src/pipeline/planner.test.ts +159 -40
- package/src/pipeline/planner.ts +108 -60
- package/src/pipeline/tier-assess.test.ts +89 -0
- package/src/pipeline/webhook.ts +424 -251
- package/src/tools/claude-tool.ts +6 -0
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/code-tool.ts +2 -2
- package/src/tools/dispatch-history-tool.test.ts +315 -0
- package/src/tools/planner-tools.test.ts +1 -1
- package/src/tools/planner-tools.ts +10 -2
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.1",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
"maxReworkAttempts": { "type": "number", "description": "Max audit failures before escalation", "default": 2 },
|
|
59
59
|
"inactivitySec": { "type": "number", "description": "Kill sessions with no I/O for this many seconds (default: 120)", "default": 120 },
|
|
60
60
|
"maxTotalSec": { "type": "number", "description": "Max total runtime for agent sessions in seconds (default: 7200)", "default": 7200 },
|
|
61
|
-
"toolTimeoutSec": { "type": "number", "description": "Max runtime for a single code_run CLI invocation in seconds (default: 600)", "default": 600 }
|
|
61
|
+
"toolTimeoutSec": { "type": "number", "description": "Max runtime for a single code_run CLI invocation in seconds (default: 600)", "default": 600 },
|
|
62
|
+
"claudeApiKey": { "type": "string", "description": "Anthropic API key for Claude CLI backend (passed as ANTHROPIC_API_KEY env var)", "sensitive": true }
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
}
|
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:
|
|
@@ -89,14 +96,23 @@ planner:
|
|
|
89
96
|
- Issues under epics are concrete deliverables with acceptance criteria.
|
|
90
97
|
- Sub-issues are atomic work units that together complete a parent issue.
|
|
91
98
|
- Use "blocks" relationships to express ordering: if A must finish before B starts, A blocks B.
|
|
92
|
-
- Every issue description must include clear acceptance criteria.
|
|
93
99
|
- Every non-epic issue needs a story point estimate and priority.
|
|
100
|
+
- Every issue description must include:
|
|
101
|
+
- A user story: "As a [role], I want [feature] so that [benefit]"
|
|
102
|
+
- Acceptance criteria in Given/When/Then format
|
|
103
|
+
- At least one UAT test scenario describing how to verify the feature manually
|
|
104
|
+
- If the user skips acceptance criteria, write reasonable defaults and confirm with them.
|
|
94
105
|
|
|
95
106
|
INTERVIEW APPROACH:
|
|
96
107
|
- Ask ONE focused question at a time. Never dump a questionnaire.
|
|
97
108
|
- After each user response, create or update issues to capture what you learned.
|
|
98
109
|
- Briefly summarize what you added before asking your next question.
|
|
110
|
+
- After capturing a feature, ask for acceptance criteria: "How would you know this is working? What should a user be able to do?"
|
|
111
|
+
- Proactively suggest UAT scenarios: "Here's how I'd test this — does that cover it?"
|
|
112
|
+
- Don't front-load all questions — weave user stories and acceptance criteria into the natural conversation.
|
|
99
113
|
- When the plan feels complete, invite the user to say "finalize plan".
|
|
114
|
+
- If the user is vague ("make it better", "you decide"), propose concrete options and ask them to pick.
|
|
115
|
+
- 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
116
|
|
|
101
117
|
interview: |
|
|
102
118
|
## Project: {{projectName}} ({{rootIdentifier}})
|
|
@@ -123,4 +139,28 @@ planner:
|
|
|
123
139
|
features you want to build, then structure everything into Linear issues with proper
|
|
124
140
|
epic hierarchy and dependency chains.
|
|
125
141
|
|
|
142
|
+
**How this works:**
|
|
143
|
+
- I'll ask questions one at a time and create issues as we go
|
|
144
|
+
- When you're happy with the plan, say **"finalize plan"** — I'll validate it and start dispatching
|
|
145
|
+
- If you want to stop, say **"abandon planning"**
|
|
146
|
+
|
|
126
147
|
Let's start — what is this project about, and what are the main feature areas?
|
|
148
|
+
|
|
149
|
+
review: |
|
|
150
|
+
## Plan Review for {{projectName}}
|
|
151
|
+
|
|
152
|
+
The plan passed all deterministic checks ({{issueCount}} issues, valid DAG, all have estimates and priorities).
|
|
153
|
+
|
|
154
|
+
### Current Plan
|
|
155
|
+
{{planSnapshot}}
|
|
156
|
+
|
|
157
|
+
### {{reviewModel}}'s Recommendations
|
|
158
|
+
{{crossModelFeedback}}
|
|
159
|
+
|
|
160
|
+
Your job:
|
|
161
|
+
1. Evaluate the recommendations above — which ones are worth applying?
|
|
162
|
+
2. If any are good, use your tools to update the relevant issues now
|
|
163
|
+
3. Summarize what you changed (if anything) and what you didn't change (and why)
|
|
164
|
+
4. End with: "If you're happy with this plan, say **approve plan** to start dispatching."
|
|
165
|
+
|
|
166
|
+
Post your review as a comment.
|
|
@@ -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,198 @@ 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
|
+
|
|
207
|
+
describe("runAgent date/time injection", () => {
|
|
208
|
+
it("injects current date/time into the message sent to subprocess", async () => {
|
|
209
|
+
const api = createApi();
|
|
210
|
+
const runCmd = vi.fn().mockResolvedValue({
|
|
211
|
+
code: 0,
|
|
212
|
+
stdout: JSON.stringify({ result: { payloads: [{ text: "done" }] } }),
|
|
213
|
+
stderr: "",
|
|
214
|
+
});
|
|
215
|
+
(api.runtime.system as any).runCommandWithTimeout = runCmd;
|
|
216
|
+
|
|
217
|
+
await runAgent({
|
|
218
|
+
api,
|
|
219
|
+
agentId: "test",
|
|
220
|
+
sessionId: "s1",
|
|
221
|
+
message: "do something",
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// The --message arg should contain the date context prefix
|
|
225
|
+
const args: string[] = runCmd.mock.calls[0][0];
|
|
226
|
+
const msgIdx = args.indexOf("--message");
|
|
227
|
+
const passedMessage = args[msgIdx + 1];
|
|
228
|
+
expect(passedMessage).toMatch(/^\[Current date\/time:.*\d{4}.*\]/);
|
|
229
|
+
expect(passedMessage).toContain("do something");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("includes ISO timestamp in the injected context", async () => {
|
|
233
|
+
const api = createApi();
|
|
234
|
+
const runCmd = vi.fn().mockResolvedValue({
|
|
235
|
+
code: 0,
|
|
236
|
+
stdout: "ok",
|
|
237
|
+
stderr: "",
|
|
238
|
+
});
|
|
239
|
+
(api.runtime.system as any).runCommandWithTimeout = runCmd;
|
|
240
|
+
|
|
241
|
+
await runAgent({
|
|
242
|
+
api,
|
|
243
|
+
agentId: "test",
|
|
244
|
+
sessionId: "s1",
|
|
245
|
+
message: "test task",
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const args: string[] = runCmd.mock.calls[0][0];
|
|
249
|
+
const msgIdx = args.indexOf("--message");
|
|
250
|
+
const passedMessage = args[msgIdx + 1];
|
|
251
|
+
// Should contain ISO format like 2026-02-19T05:45:00.000Z
|
|
252
|
+
expect(passedMessage).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
64
256
|
describe("runAgent retry wrapper", () => {
|
|
65
257
|
it("returns success on first attempt when no watchdog kill", async () => {
|
|
66
258
|
const api = createApi();
|
package/src/agent/agent.ts
CHANGED
|
@@ -95,6 +95,27 @@ export async function runAgent(params: {
|
|
|
95
95
|
return { success: false, output: "Watchdog retry exhausted" };
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Date/time injection — every LLM request gets the current timestamp so models
|
|
100
|
+
// don't hallucinate the year (Kimi K2.5 thinks it's 2025).
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
function buildDateContext(): string {
|
|
104
|
+
const now = new Date();
|
|
105
|
+
const iso = now.toISOString();
|
|
106
|
+
// Human-readable: "Tuesday, February 18, 2026, 11:42 PM CST"
|
|
107
|
+
const human = now.toLocaleString("en-US", {
|
|
108
|
+
weekday: "long",
|
|
109
|
+
year: "numeric",
|
|
110
|
+
month: "long",
|
|
111
|
+
day: "numeric",
|
|
112
|
+
hour: "numeric",
|
|
113
|
+
minute: "2-digit",
|
|
114
|
+
timeZoneName: "short",
|
|
115
|
+
});
|
|
116
|
+
return `[Current date/time: ${human} (${iso})]`;
|
|
117
|
+
}
|
|
118
|
+
|
|
98
119
|
/**
|
|
99
120
|
* Single attempt to run an agent (no retry logic).
|
|
100
121
|
*/
|
|
@@ -106,7 +127,11 @@ async function runAgentOnce(params: {
|
|
|
106
127
|
timeoutMs?: number;
|
|
107
128
|
streaming?: AgentStreamCallbacks;
|
|
108
129
|
}): Promise<AgentRunResult> {
|
|
109
|
-
const { api, agentId, sessionId,
|
|
130
|
+
const { api, agentId, sessionId, streaming } = params;
|
|
131
|
+
|
|
132
|
+
// Inject current timestamp into every LLM request
|
|
133
|
+
const message = `${buildDateContext()}\n\n${params.message}`;
|
|
134
|
+
|
|
110
135
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
111
136
|
const wdConfig = resolveWatchdogConfig(agentId, pluginConfig);
|
|
112
137
|
const timeoutMs = params.timeoutMs ?? wdConfig.maxTotalMs;
|