@vincentwei1021/synapse-openclaw-plugin 0.5.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 +95 -0
- package/openclaw.plugin.json +28 -0
- package/package.json +37 -0
- package/src/commands.ts +151 -0
- package/src/config.ts +56 -0
- package/src/event-router.test.ts +265 -0
- package/src/event-router.ts +360 -0
- package/src/index.ts +152 -0
- package/src/mcp-client.test.ts +130 -0
- package/src/mcp-client.ts +144 -0
- package/src/sse-listener.test.ts +150 -0
- package/src/sse-listener.ts +184 -0
- package/src/tools/common-tool-definitions.ts +681 -0
- package/src/tools/common-tools.ts +8 -0
- package/src/tools/tool-registry.ts +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @vincentwei1021/synapse-openclaw-plugin
|
|
2
|
+
|
|
3
|
+
OpenClaw plugin for [Synapse](https://github.com/Vincentwei1021/Synapse) -- the AI research orchestration platform.
|
|
4
|
+
|
|
5
|
+
Connects OpenClaw agents to Synapse via a persistent SSE connection and MCP tool bridge, enabling autonomous experiment execution, deep research, progress reporting, and report generation.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
openclaw plugins install @vincentwei1021/synapse-openclaw-plugin
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
Add to `~/.openclaw/openclaw.json`:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"hooks": {
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"token": "your-hooks-token"
|
|
22
|
+
},
|
|
23
|
+
"plugins": {
|
|
24
|
+
"enabled": true,
|
|
25
|
+
"entries": {
|
|
26
|
+
"synapse-openclaw-plugin": {
|
|
27
|
+
"enabled": true,
|
|
28
|
+
"config": {
|
|
29
|
+
"synapseUrl": "https://synapse.example.com",
|
|
30
|
+
"apiKey": "syn_your_api_key_here",
|
|
31
|
+
"autoStart": true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Field | Type | Required | Default | Description |
|
|
40
|
+
|-------|------|----------|---------|-------------|
|
|
41
|
+
| `synapseUrl` | `string` | Yes | -- | Synapse server URL |
|
|
42
|
+
| `apiKey` | `string` | Yes | -- | Synapse API key (`syn_` prefix) |
|
|
43
|
+
| `projectUuids` | `string[]` | No | `[]` | Project UUIDs to monitor (empty = all) |
|
|
44
|
+
| `autoStart` | `boolean` | No | `true` | Auto-claim experiment runs on assignment |
|
|
45
|
+
|
|
46
|
+
## How It Works
|
|
47
|
+
|
|
48
|
+
1. **SSE listener** maintains a persistent connection to `/api/events/notifications` for real-time events
|
|
49
|
+
2. **Event router** maps notifications to agent actions (experiment assignments, autonomous loop, deep research, mentions, etc.)
|
|
50
|
+
3. **Agent trigger** dispatches isolated agent turns via OpenClaw's `/hooks/agent` endpoint
|
|
51
|
+
4. **MCP tools** are registered as native OpenClaw agent tools, bridging to Synapse's MCP server at `/api/mcp`
|
|
52
|
+
|
|
53
|
+
### Event Handling
|
|
54
|
+
|
|
55
|
+
| Event | Behavior |
|
|
56
|
+
|-------|----------|
|
|
57
|
+
| `task_assigned` (experiment) | Fetch experiment + project context, wake agent with full assignment prompt |
|
|
58
|
+
| `autonomous_loop_triggered` | Wake agent to analyze project and propose new experiments |
|
|
59
|
+
| `deep_research_requested` | Wake agent to perform literature review |
|
|
60
|
+
| `experiment_report_requested` | Wake agent to write a detailed experiment report |
|
|
61
|
+
| `mentioned` | Wake agent with @mention context |
|
|
62
|
+
| `hypothesis_formulation_requested` | Wake agent to review hypothesis formulation questions |
|
|
63
|
+
| `hypothesis_formulation_answered` | Wake agent to validate answers |
|
|
64
|
+
| `research_question_claimed` | Wake agent when assigned a research question |
|
|
65
|
+
|
|
66
|
+
### Registered MCP Tools
|
|
67
|
+
|
|
68
|
+
All Synapse MCP tools are available to all agents. The plugin registers them as native OpenClaw tools via passthrough to the Synapse MCP server.
|
|
69
|
+
|
|
70
|
+
## Local Development
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"plugins": {
|
|
75
|
+
"enabled": true,
|
|
76
|
+
"load": {
|
|
77
|
+
"paths": ["/path/to/Synapse/packages/openclaw-plugin"]
|
|
78
|
+
},
|
|
79
|
+
"entries": {
|
|
80
|
+
"synapse-openclaw-plugin": {
|
|
81
|
+
"enabled": true,
|
|
82
|
+
"config": {
|
|
83
|
+
"synapseUrl": "http://localhost:3000",
|
|
84
|
+
"apiKey": "syn_your_dev_key",
|
|
85
|
+
"autoStart": true
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "synapse-openclaw-plugin",
|
|
3
|
+
"configSchema": {
|
|
4
|
+
"type": "object",
|
|
5
|
+
"additionalProperties": false,
|
|
6
|
+
"properties": {
|
|
7
|
+
"synapseUrl": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"description": "Synapse server URL"
|
|
10
|
+
},
|
|
11
|
+
"apiKey": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"description": "Synapse API Key (syn_ prefix)"
|
|
14
|
+
},
|
|
15
|
+
"projectUuids": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": { "type": "string" },
|
|
18
|
+
"default": [],
|
|
19
|
+
"description": "Project UUIDs to monitor (empty = all)"
|
|
20
|
+
},
|
|
21
|
+
"autoStart": {
|
|
22
|
+
"type": "boolean",
|
|
23
|
+
"default": true,
|
|
24
|
+
"description": "Auto-claim experiment runs on assignment"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vincentwei1021/synapse-openclaw-plugin",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "OpenClaw plugin for Synapse research orchestration — SSE notifications, MCP tools, experiment lifecycle, autonomous loop",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"test": "cd ../.. && pnpm exec vitest run packages/openclaw-plugin/src/event-router.test.ts packages/openclaw-plugin/src/mcp-client.test.ts packages/openclaw-plugin/src/sse-listener.test.ts"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"openclaw": ">=2026.0.0"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
17
|
+
"zod": "^3.23.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"typescript": "^5.9.0"
|
|
21
|
+
},
|
|
22
|
+
"openclaw": {
|
|
23
|
+
"extensions": ["src/index.ts"]
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/Vincentwei1021/Synapse",
|
|
28
|
+
"directory": "packages/openclaw-plugin"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/Vincentwei1021/Synapse",
|
|
31
|
+
"keywords": ["openclaw", "synapse", "research", "mcp", "plugin", "agent", "experiments"],
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"openclaw.plugin.json",
|
|
35
|
+
"README.md"
|
|
36
|
+
]
|
|
37
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { SynapseMcpClient } from "./mcp-client.js";
|
|
2
|
+
|
|
3
|
+
// ===== Response types from Synapse MCP tools =====
|
|
4
|
+
|
|
5
|
+
interface CheckinResponse {
|
|
6
|
+
checkinTime: string;
|
|
7
|
+
agent: {
|
|
8
|
+
uuid: string;
|
|
9
|
+
name: string;
|
|
10
|
+
roles: string[];
|
|
11
|
+
persona: string | null;
|
|
12
|
+
systemPrompt: string | null;
|
|
13
|
+
};
|
|
14
|
+
assignments: {
|
|
15
|
+
researchQuestions?: AssignedQuestion[];
|
|
16
|
+
experimentRuns?: AssignedRun[];
|
|
17
|
+
};
|
|
18
|
+
pending: {
|
|
19
|
+
researchQuestionsCount?: number;
|
|
20
|
+
experimentRunsCount?: number;
|
|
21
|
+
};
|
|
22
|
+
notifications: {
|
|
23
|
+
unreadCount: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface AssignedQuestion {
|
|
28
|
+
uuid: string;
|
|
29
|
+
title: string;
|
|
30
|
+
status: string;
|
|
31
|
+
project: { uuid: string; name: string };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface AssignedRun {
|
|
35
|
+
uuid: string;
|
|
36
|
+
title: string;
|
|
37
|
+
status: string;
|
|
38
|
+
priority: string;
|
|
39
|
+
project: { uuid: string; name: string };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AssignmentsResponse {
|
|
43
|
+
researchQuestions?: AssignedQuestion[];
|
|
44
|
+
experimentRuns?: AssignedRun[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ===== Formatting helpers =====
|
|
48
|
+
|
|
49
|
+
function formatStatus(checkin: CheckinResponse, connectionStatus: string): string {
|
|
50
|
+
const questionCount = checkin?.pending?.researchQuestionsCount ?? 0;
|
|
51
|
+
const runCount = checkin?.pending?.experimentRunsCount ?? 0;
|
|
52
|
+
const lines: string[] = [
|
|
53
|
+
`Connection: ${connectionStatus}`,
|
|
54
|
+
`Assignments: ${questionCount} questions, ${runCount} experiments`,
|
|
55
|
+
`Notifications: ${checkin?.notifications?.unreadCount ?? 0} unread`,
|
|
56
|
+
];
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatExperimentList(runs: AssignedRun[] | undefined): string {
|
|
61
|
+
if (!runs?.length) {
|
|
62
|
+
return "No assigned experiments.";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const lines = runs.map(
|
|
66
|
+
(r) => `[${r.status}] [${r.priority}] ${r.title} (${r.project.name})`
|
|
67
|
+
);
|
|
68
|
+
return `Assigned experiments (${runs.length}):\n${lines.join("\n")}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatQuestionList(questions: AssignedQuestion[] | undefined): string {
|
|
72
|
+
if (!questions?.length) {
|
|
73
|
+
return "No assigned research questions.";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lines = questions.map(
|
|
77
|
+
(q) => `[${q.status}] ${q.title} (${q.project.name})`
|
|
78
|
+
);
|
|
79
|
+
return `Assigned research questions (${questions.length}):\n${lines.join("\n")}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const HELP_TEXT = [
|
|
83
|
+
"Synapse commands:",
|
|
84
|
+
" /synapse Show connection status and summary",
|
|
85
|
+
" /synapse status Same as above",
|
|
86
|
+
" /synapse experiments List assigned experiments",
|
|
87
|
+
" /synapse questions List assigned research questions",
|
|
88
|
+
].join("\n");
|
|
89
|
+
|
|
90
|
+
interface CommandRegistry {
|
|
91
|
+
registerCommand(command: {
|
|
92
|
+
name: string;
|
|
93
|
+
description: string;
|
|
94
|
+
handler: (ctx: { args: string }) => Promise<{ text: string }>;
|
|
95
|
+
}): void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ===== Registration =====
|
|
99
|
+
|
|
100
|
+
export function registerSynapseCommands(
|
|
101
|
+
api: CommandRegistry,
|
|
102
|
+
mcpClient: SynapseMcpClient,
|
|
103
|
+
getStatus: () => string
|
|
104
|
+
): void {
|
|
105
|
+
api.registerCommand({
|
|
106
|
+
name: "synapse",
|
|
107
|
+
description: "Synapse plugin commands: status, experiments, questions",
|
|
108
|
+
async handler(ctx: { args: string }) {
|
|
109
|
+
const sub = (ctx.args ?? "").trim().toLowerCase();
|
|
110
|
+
|
|
111
|
+
// /synapse or /synapse status
|
|
112
|
+
if (!sub || sub === "status") {
|
|
113
|
+
try {
|
|
114
|
+
const checkin = (await mcpClient.callTool("synapse_checkin", {})) as CheckinResponse;
|
|
115
|
+
return { text: formatStatus(checkin, getStatus()) };
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return { text: `Failed to check in: ${err instanceof Error ? err.message : String(err)}` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// /synapse experiments
|
|
122
|
+
if (sub === "experiments" || sub === "tasks") {
|
|
123
|
+
try {
|
|
124
|
+
const data = (await mcpClient.callTool(
|
|
125
|
+
"synapse_get_my_assignments",
|
|
126
|
+
{}
|
|
127
|
+
)) as AssignmentsResponse;
|
|
128
|
+
return { text: formatExperimentList(data?.experimentRuns) };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return { text: `Failed to fetch experiments: ${err instanceof Error ? err.message : String(err)}` };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// /synapse questions
|
|
135
|
+
if (sub === "questions" || sub === "ideas") {
|
|
136
|
+
try {
|
|
137
|
+
const data = (await mcpClient.callTool(
|
|
138
|
+
"synapse_get_my_assignments",
|
|
139
|
+
{}
|
|
140
|
+
)) as AssignmentsResponse;
|
|
141
|
+
return { text: formatQuestionList(data?.researchQuestions) };
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return { text: `Failed to fetch research questions: ${err instanceof Error ? err.message : String(err)}` };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Unknown subcommand
|
|
148
|
+
return { text: HELP_TEXT };
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const CONFIG_FILE_PATH = "~/.openclaw/openclaw.json";
|
|
4
|
+
export const CONFIG_KEY_PATH = "plugins.entries.synapse-openclaw-plugin.config";
|
|
5
|
+
|
|
6
|
+
export const synapseConfigSchema = z.object({
|
|
7
|
+
synapseUrl: z
|
|
8
|
+
.string()
|
|
9
|
+
.url()
|
|
10
|
+
.optional()
|
|
11
|
+
.describe("Synapse server URL (e.g. https://synapse.example.com)"),
|
|
12
|
+
apiKey: z
|
|
13
|
+
.string()
|
|
14
|
+
.startsWith("syn_")
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("Synapse API Key (syn_ prefix)"),
|
|
17
|
+
projectUuids: z
|
|
18
|
+
.array(z.string().uuid())
|
|
19
|
+
.optional()
|
|
20
|
+
.default([])
|
|
21
|
+
.describe("Project UUIDs to monitor. Empty = all projects"),
|
|
22
|
+
autoStart: z
|
|
23
|
+
.boolean()
|
|
24
|
+
.optional()
|
|
25
|
+
.default(true)
|
|
26
|
+
.describe("Auto-claim and start work on task_assigned events"),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export type SynapsePluginConfig = z.infer<typeof synapseConfigSchema>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check required config fields and warn about missing ones.
|
|
33
|
+
* Returns true if all required fields are present, false otherwise.
|
|
34
|
+
*/
|
|
35
|
+
export function validateConfigWithWarnings(
|
|
36
|
+
config: SynapsePluginConfig,
|
|
37
|
+
logger: { warn: (msg: string) => void },
|
|
38
|
+
): boolean {
|
|
39
|
+
const missing: string[] = [];
|
|
40
|
+
|
|
41
|
+
if (!config.synapseUrl) {
|
|
42
|
+
missing.push(` - "synapseUrl": set at ${CONFIG_KEY_PATH}.synapseUrl in ${CONFIG_FILE_PATH}`);
|
|
43
|
+
}
|
|
44
|
+
if (!config.apiKey) {
|
|
45
|
+
missing.push(` - "apiKey": set at ${CONFIG_KEY_PATH}.apiKey in ${CONFIG_FILE_PATH}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (missing.length > 0) {
|
|
49
|
+
logger.warn(
|
|
50
|
+
`[Synapse] Plugin is missing required configuration. Features will be disabled until configured:\n` +
|
|
51
|
+
missing.join("\n")
|
|
52
|
+
);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { SynapseEventRouter } from "./event-router.js";
|
|
3
|
+
|
|
4
|
+
function createLogger() {
|
|
5
|
+
return {
|
|
6
|
+
info: vi.fn(),
|
|
7
|
+
warn: vi.fn(),
|
|
8
|
+
error: vi.fn(),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("SynapseEventRouter", () => {
|
|
13
|
+
const triggerAgent = vi.fn();
|
|
14
|
+
const callTool = vi.fn();
|
|
15
|
+
const logger = createLogger();
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("routes experiment assignments with full context and unlimited budget text", async () => {
|
|
22
|
+
callTool
|
|
23
|
+
.mockResolvedValueOnce({
|
|
24
|
+
notifications: [
|
|
25
|
+
{
|
|
26
|
+
uuid: "notification-1",
|
|
27
|
+
researchProjectUuid: "project-1",
|
|
28
|
+
entityType: "experiment",
|
|
29
|
+
entityUuid: "experiment-1",
|
|
30
|
+
entityTitle: "Train the baseline",
|
|
31
|
+
action: "task_assigned",
|
|
32
|
+
message: "Assigned to you",
|
|
33
|
+
actorType: "user",
|
|
34
|
+
actorUuid: "user-1",
|
|
35
|
+
actorName: "Alice",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
})
|
|
39
|
+
.mockResolvedValueOnce({
|
|
40
|
+
experiment: {
|
|
41
|
+
uuid: "experiment-1",
|
|
42
|
+
researchProjectUuid: "project-1",
|
|
43
|
+
title: "Train the baseline",
|
|
44
|
+
description: "Run the first baseline.",
|
|
45
|
+
priority: "high",
|
|
46
|
+
computeBudgetHours: null,
|
|
47
|
+
attachments: [{ originalName: "spec.md" }],
|
|
48
|
+
researchQuestion: { uuid: "question-1", title: "Why is recall dropping?" },
|
|
49
|
+
parentQuestionExperiments: [],
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
.mockResolvedValueOnce({
|
|
53
|
+
uuid: "project-1",
|
|
54
|
+
name: "Recall recovery",
|
|
55
|
+
description: "Improve retrieval quality",
|
|
56
|
+
goal: "Raise recall by 5 points",
|
|
57
|
+
datasets: ["train.jsonl"],
|
|
58
|
+
evaluationMethods: ["recall@10"],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const router = new SynapseEventRouter({
|
|
62
|
+
mcpClient: { callTool } as never,
|
|
63
|
+
config: {
|
|
64
|
+
synapseUrl: "http://synapse.local",
|
|
65
|
+
apiKey: "syn_key",
|
|
66
|
+
autoStart: true,
|
|
67
|
+
projectUuids: [],
|
|
68
|
+
},
|
|
69
|
+
triggerAgent,
|
|
70
|
+
logger,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await (router as unknown as { fetchAndRoute: (notificationUuid: string) => Promise<void> }).fetchAndRoute("notification-1");
|
|
74
|
+
|
|
75
|
+
expect(triggerAgent).toHaveBeenCalledTimes(1);
|
|
76
|
+
const [prompt, metadata] = triggerAgent.mock.calls[0];
|
|
77
|
+
expect(prompt).toContain("Experiment assigned: Train the baseline");
|
|
78
|
+
expect(prompt).toContain("Compute budget (hours): Unlimited");
|
|
79
|
+
expect(prompt).toContain("post a comment on this experiment");
|
|
80
|
+
expect(prompt).toContain("@[Alice](user:user-1)");
|
|
81
|
+
expect(prompt).toContain("synapse_report_experiment_progress");
|
|
82
|
+
expect(metadata).toMatchObject({
|
|
83
|
+
action: "task_assigned",
|
|
84
|
+
entityType: "experiment",
|
|
85
|
+
entityUuid: "experiment-1",
|
|
86
|
+
projectUuid: "project-1",
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("skips notifications outside the configured project filter", async () => {
|
|
91
|
+
callTool.mockResolvedValueOnce({
|
|
92
|
+
notifications: [
|
|
93
|
+
{
|
|
94
|
+
uuid: "notification-2",
|
|
95
|
+
researchProjectUuid: "project-2",
|
|
96
|
+
entityType: "experiment",
|
|
97
|
+
entityUuid: "experiment-2",
|
|
98
|
+
entityTitle: "Ignored experiment",
|
|
99
|
+
action: "task_assigned",
|
|
100
|
+
message: "Assigned to you",
|
|
101
|
+
actorType: "user",
|
|
102
|
+
actorUuid: "user-2",
|
|
103
|
+
actorName: "Bob",
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const router = new SynapseEventRouter({
|
|
109
|
+
mcpClient: { callTool } as never,
|
|
110
|
+
config: {
|
|
111
|
+
synapseUrl: "http://synapse.local",
|
|
112
|
+
apiKey: "syn_key",
|
|
113
|
+
autoStart: true,
|
|
114
|
+
projectUuids: ["project-allowed"],
|
|
115
|
+
},
|
|
116
|
+
triggerAgent,
|
|
117
|
+
logger,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await (router as unknown as { fetchAndRoute: (notificationUuid: string) => Promise<void> }).fetchAndRoute("notification-2");
|
|
121
|
+
|
|
122
|
+
expect(triggerAgent).not.toHaveBeenCalled();
|
|
123
|
+
expect(callTool).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(logger.info).toHaveBeenCalledWith("Notification for project project-2 filtered out");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("routes autonomous loop triggered events", async () => {
|
|
128
|
+
callTool.mockResolvedValueOnce({
|
|
129
|
+
notifications: [
|
|
130
|
+
{
|
|
131
|
+
uuid: "notification-3",
|
|
132
|
+
researchProjectUuid: "project-1",
|
|
133
|
+
entityType: "research_project",
|
|
134
|
+
entityUuid: "project-1",
|
|
135
|
+
entityTitle: "My Project",
|
|
136
|
+
action: "autonomous_loop_triggered",
|
|
137
|
+
message: "Queue empty",
|
|
138
|
+
actorType: "system",
|
|
139
|
+
actorUuid: "system",
|
|
140
|
+
actorName: "Synapse",
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const router = new SynapseEventRouter({
|
|
146
|
+
mcpClient: { callTool } as never,
|
|
147
|
+
config: {
|
|
148
|
+
synapseUrl: "http://synapse.local",
|
|
149
|
+
apiKey: "syn_key",
|
|
150
|
+
autoStart: true,
|
|
151
|
+
projectUuids: [],
|
|
152
|
+
},
|
|
153
|
+
triggerAgent,
|
|
154
|
+
logger,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await (router as unknown as { fetchAndRoute: (notificationUuid: string) => Promise<void> }).fetchAndRoute("notification-3");
|
|
158
|
+
|
|
159
|
+
expect(triggerAgent).toHaveBeenCalledTimes(1);
|
|
160
|
+
const [prompt, metadata] = triggerAgent.mock.calls[0];
|
|
161
|
+
expect(prompt).toContain("Autonomous research loop triggered");
|
|
162
|
+
expect(prompt).toContain("synapse_propose_experiment");
|
|
163
|
+
expect(metadata).toMatchObject({
|
|
164
|
+
action: "autonomous_loop_triggered",
|
|
165
|
+
projectUuid: "project-1",
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("routes experiment report requested events", async () => {
|
|
170
|
+
callTool.mockResolvedValueOnce({
|
|
171
|
+
notifications: [
|
|
172
|
+
{
|
|
173
|
+
uuid: "notification-4",
|
|
174
|
+
researchProjectUuid: "project-1",
|
|
175
|
+
entityType: "experiment",
|
|
176
|
+
entityUuid: "experiment-1",
|
|
177
|
+
entityTitle: "Baseline experiment",
|
|
178
|
+
action: "experiment_report_requested",
|
|
179
|
+
message: "Write report",
|
|
180
|
+
actorType: "system",
|
|
181
|
+
actorUuid: "system",
|
|
182
|
+
actorName: "Synapse",
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const router = new SynapseEventRouter({
|
|
188
|
+
mcpClient: { callTool } as never,
|
|
189
|
+
config: {
|
|
190
|
+
synapseUrl: "http://synapse.local",
|
|
191
|
+
apiKey: "syn_key",
|
|
192
|
+
autoStart: true,
|
|
193
|
+
projectUuids: [],
|
|
194
|
+
},
|
|
195
|
+
triggerAgent,
|
|
196
|
+
logger,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await (router as unknown as { fetchAndRoute: (notificationUuid: string) => Promise<void> }).fetchAndRoute("notification-4");
|
|
200
|
+
|
|
201
|
+
expect(triggerAgent).toHaveBeenCalledTimes(1);
|
|
202
|
+
const [prompt] = triggerAgent.mock.calls[0];
|
|
203
|
+
expect(prompt).toContain("Baseline experiment");
|
|
204
|
+
expect(prompt).toContain("synapse_add_comment");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("routes @mention events with entity context", async () => {
|
|
208
|
+
callTool.mockResolvedValueOnce({
|
|
209
|
+
notifications: [
|
|
210
|
+
{
|
|
211
|
+
uuid: "notification-5",
|
|
212
|
+
researchProjectUuid: "project-1",
|
|
213
|
+
entityType: "experiment",
|
|
214
|
+
entityUuid: "experiment-1",
|
|
215
|
+
entityTitle: "Recall test",
|
|
216
|
+
action: "mentioned",
|
|
217
|
+
message: "@Agent please review",
|
|
218
|
+
actorType: "user",
|
|
219
|
+
actorUuid: "user-1",
|
|
220
|
+
actorName: "Alice",
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const router = new SynapseEventRouter({
|
|
226
|
+
mcpClient: { callTool } as never,
|
|
227
|
+
config: {
|
|
228
|
+
synapseUrl: "http://synapse.local",
|
|
229
|
+
apiKey: "syn_key",
|
|
230
|
+
autoStart: true,
|
|
231
|
+
projectUuids: [],
|
|
232
|
+
},
|
|
233
|
+
triggerAgent,
|
|
234
|
+
logger,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await (router as unknown as { fetchAndRoute: (notificationUuid: string) => Promise<void> }).fetchAndRoute("notification-5");
|
|
238
|
+
|
|
239
|
+
expect(triggerAgent).toHaveBeenCalledTimes(1);
|
|
240
|
+
const [prompt] = triggerAgent.mock.calls[0];
|
|
241
|
+
expect(prompt).toContain("@mentioned");
|
|
242
|
+
expect(prompt).toContain("synapse_get_comments");
|
|
243
|
+
expect(prompt).toContain("@[Alice](user:user-1)");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("ignores non-new_notification event types", () => {
|
|
247
|
+
const router = new SynapseEventRouter({
|
|
248
|
+
mcpClient: { callTool } as never,
|
|
249
|
+
config: {
|
|
250
|
+
synapseUrl: "http://synapse.local",
|
|
251
|
+
apiKey: "syn_key",
|
|
252
|
+
autoStart: true,
|
|
253
|
+
projectUuids: [],
|
|
254
|
+
},
|
|
255
|
+
triggerAgent,
|
|
256
|
+
logger,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
router.dispatch({ type: "count_update", unreadCount: 5 } as unknown as import("./sse-listener.js").SseNotificationEvent);
|
|
260
|
+
|
|
261
|
+
expect(triggerAgent).not.toHaveBeenCalled();
|
|
262
|
+
expect(callTool).not.toHaveBeenCalled();
|
|
263
|
+
expect(logger.info).toHaveBeenCalledWith('SSE event type "count_update" ignored');
|
|
264
|
+
});
|
|
265
|
+
});
|